| author | |
| committer | |
| log | 6f405370c8e552da9dcfdd6922e85789fd918340 |
| tree | 2ae20c69ee579c3d26eb5ea6f879c35cd90237a1 |
| parent | dddc0bdcb2230147e207efb17df2e49dbe1bdd8c |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 404 insertions(+), 9 deletions(-)
apps/web/index.html+25-1| ... | @@ -452,6 +452,30 @@ | ... | @@ -452,6 +452,30 @@ |
| 452 | height: 64px; | 452 | height: 64px; |
| 453 | object-fit: contain; | 453 | object-fit: contain; |
| 454 | } | 454 | } |
| 455 | |||
| 456 | #boot-error { | ||
| 457 | display: grid; | ||
| 458 | justify-items: center; | ||
| 459 | gap: 16px; | ||
| 460 | max-width: 600px; | ||
| 461 | padding: 24px; | ||
| 462 | text-align: center; | ||
| 463 | } | ||
| 464 | |||
| 465 | #boot-error p { | ||
| 466 | margin: 0; | ||
| 467 | overflow-wrap: anywhere; | ||
| 468 | } | ||
| 469 | |||
| 470 | #boot-error button { | ||
| 471 | padding: 8px 16px; | ||
| 472 | border: 1px solid currentColor; | ||
| 473 | border-radius: 4px; | ||
| 474 | background: transparent; | ||
| 475 | color: inherit; | ||
| 476 | font: inherit; | ||
| 477 | cursor: pointer; | ||
| 478 | } | ||
| 455 | </style> | 479 | </style> |
| 456 | <title>T3 Code (Alpha)</title> | 480 | <title>T3 Code (Alpha)</title> |
| 457 | </head> | 481 | </head> |
| ... | @@ -463,6 +487,6 @@ | ... | @@ -463,6 +487,6 @@ |
| 463 | </div> | 487 | </div> |
| 464 | </div> | 488 | </div> |
| 465 | </div> | 489 | </div> |
| 466 | <script type="module" src="/src/main.tsx"></script> | 490 | <script type="module" src="/src/bootstrap.ts"></script> |
| 467 | </body> | 491 | </body> |
| 468 | </html> | 492 | </html> |
apps/web/src/bootstrap.test.ts created+92| ... | @@ -0,0 +1,92 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { showBootError } from "./lib/bootError"; | ||
| 4 | |||
| 5 | class BootElement extends EventTarget { | ||
| 6 | children: BootElement[] = []; | ||
| 7 | textContent = ""; | ||
| 8 | |||
| 9 | constructor(readonly tagName: string) { | ||
| 10 | super(); | ||
| 11 | } | ||
| 12 | |||
| 13 | setAttribute() {} | ||
| 14 | |||
| 15 | append(child: BootElement) { | ||
| 16 | this.children.push(child); | ||
| 17 | } | ||
| 18 | |||
| 19 | replaceChildren(...children: BootElement[]) { | ||
| 20 | this.children = children; | ||
| 21 | } | ||
| 22 | |||
| 23 | get text(): string { | ||
| 24 | return this.textContent + this.children.map((child) => child.text).join(" "); | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | describe("app startup failures", () => { | ||
| 29 | let bootShell: BootElement | null; | ||
| 30 | |||
| 31 | beforeEach(() => { | ||
| 32 | vi.resetModules(); | ||
| 33 | vi.doMock("./main", () => { | ||
| 34 | throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); | ||
| 35 | }); | ||
| 36 | bootShell = new BootElement("div"); | ||
| 37 | vi.stubGlobal("document", { | ||
| 38 | getElementById: () => bootShell, | ||
| 39 | createElement: (tagName: string) => new BootElement(tagName), | ||
| 40 | }); | ||
| 41 | vi.spyOn(console, "error").mockImplementation(() => {}); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("shows failures from asynchronous app startup", async () => { | ||
| 45 | vi.doMock("./main", () => ({ startup: Promise.reject(new Error("Startup chunks failed")) })); | ||
| 46 | |||
| 47 | await import("./bootstrap"); | ||
| 48 | await vi.dynamicImportSettled(); | ||
| 49 | |||
| 50 | expect(bootShell?.text).toContain("Startup chunks failed"); | ||
| 51 | }); | ||
| 52 | |||
| 53 | afterEach(() => { | ||
| 54 | vi.unstubAllGlobals(); | ||
| 55 | vi.unstubAllEnvs(); | ||
| 56 | vi.restoreAllMocks(); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("replaces the splash when an app import throws before main can run", async () => { | ||
| 60 | const reload = vi.fn(); | ||
| 61 | vi.stubGlobal("window", { location: { reload } }); | ||
| 62 | |||
| 63 | await import("./bootstrap"); | ||
| 64 | await vi.dynamicImportSettled(); | ||
| 65 | |||
| 66 | expect(bootShell?.text).toContain("T3 Code could not load."); | ||
| 67 | const reloadButton = bootShell?.children[0]?.children.find( | ||
| 68 | (element) => element.tagName === "button", | ||
| 69 | ); | ||
| 70 | expect(reloadButton?.text).toBe("Reload"); | ||
| 71 | reloadButton?.dispatchEvent(new Event("click")); | ||
| 72 | expect(reload).toHaveBeenCalledOnce(); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it.each([true, false])("shows startup error details only in dev mode, DEV=%s", (dev) => { | ||
| 76 | vi.stubEnv("DEV", dev); | ||
| 77 | |||
| 78 | showBootError(new Error("internal module path")); | ||
| 79 | |||
| 80 | expect(bootShell?.text).toContain("T3 Code could not load."); | ||
| 81 | expect(bootShell?.text.includes("internal module path")).toBe(dev); | ||
| 82 | }); | ||
| 83 | |||
| 84 | it("does not replace the app after React removes the splash", () => { | ||
| 85 | bootShell = null; | ||
| 86 | const createElement = vi.spyOn(document, "createElement"); | ||
| 87 | |||
| 88 | showBootError(new Error("late failure")); | ||
| 89 | |||
| 90 | expect(createElement).not.toHaveBeenCalled(); | ||
| 91 | }); | ||
| 92 | }); | ||
apps/web/src/bootstrap.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { showBootError } from "./lib/bootError"; | ||
| 2 | |||
| 3 | // Bundled dev can move UI code into shared chunks. Load it only after this | ||
| 4 | // entry runs the React refresh preamble, and catch failures before React mounts. | ||
| 5 | void import("./main").then(({ startup }) => startup).catch(showBootError); | ||
apps/web/src/bundledDev.test.ts created+223| ... | @@ -0,0 +1,223 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - builds and executes real dev bundles on disk. | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeEvents from "node:events"; | ||
| 4 | import * as NodeFSP from "node:fs/promises"; | ||
| 5 | import * as NodeOS from "node:os"; | ||
| 6 | import * as NodePath from "node:path"; | ||
| 7 | import * as NodeURL from "node:url"; | ||
| 8 | import * as NodeUtil from "node:util"; | ||
| 9 | |||
| 10 | import react from "@vitejs/plugin-react"; | ||
| 11 | import { createLogger, createServer } from "vite-plus"; | ||
| 12 | import { expect, it } from "vite-plus/test"; | ||
| 13 | |||
| 14 | import { tailwindPlugins } from "../vite/tailwind"; | ||
| 15 | |||
| 16 | const execFile = NodeUtil.promisify(NodeChildProcess.execFile); | ||
| 17 | |||
| 18 | it("initializes React refresh before a shared UI chunk runs in bundled dev", async () => { | ||
| 19 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bootstrap-")); | ||
| 20 | const output = NodePath.join(root, "output"); | ||
| 21 | let resolveBundle!: (files: Map<string, string>) => void; | ||
| 22 | let rejectBundle!: (error: unknown) => void; | ||
| 23 | const bundled = new Promise<Map<string, string>>((resolve, reject) => { | ||
| 24 | resolveBundle = resolve; | ||
| 25 | rejectBundle = reject; | ||
| 26 | }); | ||
| 27 | let server: Awaited<ReturnType<typeof createServer>> | undefined; | ||
| 28 | |||
| 29 | try { | ||
| 30 | await NodeFSP.mkdir(NodePath.join(root, "src/lib"), { recursive: true }); | ||
| 31 | await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); | ||
| 32 | for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) { | ||
| 33 | await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file)); | ||
| 34 | } | ||
| 35 | await NodeFSP.writeFile( | ||
| 36 | NodePath.join(root, "src/shared.tsx"), | ||
| 37 | "export function Shared() { return <div>ready</div>; }", | ||
| 38 | ); | ||
| 39 | await NodeFSP.writeFile( | ||
| 40 | NodePath.join(root, "src/main.tsx"), | ||
| 41 | `import { Shared } from "./shared"; | ||
| 42 | export const startup = Promise.resolve().then(() => globalThis.onStarted(Shared()));`, | ||
| 43 | ); | ||
| 44 | |||
| 45 | server = await createServer({ | ||
| 46 | configFile: false, | ||
| 47 | root, | ||
| 48 | publicDir: NodeURL.fileURLToPath(new URL("../public", import.meta.url)), | ||
| 49 | logLevel: "silent", | ||
| 50 | resolve: { | ||
| 51 | alias: { react: NodePath.dirname(NodeURL.fileURLToPath(import.meta.resolve("react"))) }, | ||
| 52 | }, | ||
| 53 | experimental: { bundledDev: true }, | ||
| 54 | plugins: [ | ||
| 55 | react(), | ||
| 56 | { | ||
| 57 | name: "capture-bootstrap-bundle", | ||
| 58 | buildEnd(error) { | ||
| 59 | if (error) rejectBundle(error); | ||
| 60 | }, | ||
| 61 | generateBundle(_options, bundle) { | ||
| 62 | resolveBundle( | ||
| 63 | new Map( | ||
| 64 | Object.values(bundle) | ||
| 65 | .filter((file) => file.type === "chunk") | ||
| 66 | .map((file) => [file.fileName, file.code]), | ||
| 67 | ), | ||
| 68 | ); | ||
| 69 | }, | ||
| 70 | }, | ||
| 71 | ], | ||
| 72 | build: { | ||
| 73 | rolldownOptions: { | ||
| 74 | experimental: { devMode: { lazy: false } }, | ||
| 75 | output: { | ||
| 76 | // Reproduce the shared chunks Vite creates after lazy routes load, | ||
| 77 | // without needing a browser to trigger the lazy compiler first. | ||
| 78 | codeSplitting: { | ||
| 79 | groups: [ | ||
| 80 | { name: "vendor", test: /node_modules|@react-refresh/, priority: 10 }, | ||
| 81 | { name: "shared-ui", test: /shared\.tsx$/, includeDependenciesRecursively: false }, | ||
| 82 | ], | ||
| 83 | }, | ||
| 84 | }, | ||
| 85 | }, | ||
| 86 | }, | ||
| 87 | server: { host: "127.0.0.1", port: 0 }, | ||
| 88 | }); | ||
| 89 | await server.listen(); | ||
| 90 | for (const [file, code] of await bundled) { | ||
| 91 | const target = NodePath.join(output, file); | ||
| 92 | await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); | ||
| 93 | await NodeFSP.writeFile(target, code); | ||
| 94 | } | ||
| 95 | |||
| 96 | // Run the actual generated ES modules so their import order and refresh | ||
| 97 | // checks execute. These stubs replace only the browser and HMR transport. | ||
| 98 | const runner = NodePath.join(output, "check.mjs"); | ||
| 99 | await NodeFSP.writeFile( | ||
| 100 | runner, | ||
| 101 | `import assert from "node:assert/strict"; | ||
| 102 | const started = Promise.withResolvers(); | ||
| 103 | globalThis.window = globalThis; | ||
| 104 | globalThis.document = { | ||
| 105 | createElement: () => ({ relList: { supports: () => true } }), | ||
| 106 | getElementById: () => null, | ||
| 107 | }; | ||
| 108 | globalThis.__rolldown_runtime__ = { | ||
| 109 | registerGraph() {}, | ||
| 110 | registerModule() {}, | ||
| 111 | createModuleHotContext: () => ({ accept() {} }), | ||
| 112 | }; | ||
| 113 | globalThis.onStarted = started.resolve; | ||
| 114 | console.error = (_message, error) => started.reject(error); | ||
| 115 | await import("./assets/index.js"); | ||
| 116 | const element = await started.promise; | ||
| 117 | assert.equal(element.props.children, "ready"); | ||
| 118 | assert.equal(typeof window.$RefreshReg$, "function"); | ||
| 119 | console.log("App started with React refresh ready.");`, | ||
| 120 | ); | ||
| 121 | const result = await execFile("node", [runner]); | ||
| 122 | expect(result.stdout).toContain("App started with React refresh ready."); | ||
| 123 | } finally { | ||
| 124 | await server?.close(); | ||
| 125 | await NodeFSP.rm(root, { recursive: true, force: true }); | ||
| 126 | } | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("hot updates Tailwind classes when a source file changes in bundled dev", async () => { | ||
| 130 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tailwind-")); | ||
| 131 | const events = new NodeEvents.EventEmitter(); | ||
| 132 | let server: Awaited<ReturnType<typeof createServer>> | undefined; | ||
| 133 | let socket: WebSocket | undefined; | ||
| 134 | let css = ""; | ||
| 135 | |||
| 136 | try { | ||
| 137 | await NodeFSP.writeFile( | ||
| 138 | NodePath.join(root, "index.html"), | ||
| 139 | '<html><body><script type="module" src="/main.ts"></script></body></html>', | ||
| 140 | ); | ||
| 141 | const source = | ||
| 142 | 'import "./style.css"; export const margin = "m-[13px]"; import.meta.hot?.accept();'; | ||
| 143 | await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source); | ||
| 144 | await NodeFSP.writeFile( | ||
| 145 | NodePath.join(root, "style.css"), | ||
| 146 | '@import "tailwindcss" source(none); @source "./main.ts";', | ||
| 147 | ); | ||
| 148 | const logger = createLogger("silent"); | ||
| 149 | logger.error = (message) => events.emit("error", new Error(message)); | ||
| 150 | const built = NodeEvents.EventEmitter.once(events, "built"); | ||
| 151 | server = await createServer({ | ||
| 152 | configFile: false, | ||
| 153 | root, | ||
| 154 | customLogger: logger, | ||
| 155 | resolve: { | ||
| 156 | alias: { | ||
| 157 | tailwindcss: NodeURL.fileURLToPath( | ||
| 158 | new URL("../node_modules/tailwindcss/index.css", import.meta.url), | ||
| 159 | ), | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | experimental: { bundledDev: true }, | ||
| 163 | plugins: [ | ||
| 164 | ...tailwindPlugins(true), | ||
| 165 | { | ||
| 166 | name: "observe-tailwind-output", | ||
| 167 | enforce: "pre", | ||
| 168 | transform(code, id) { | ||
| 169 | if (id.endsWith("/style.css")) css = code; | ||
| 170 | }, | ||
| 171 | generateBundle() { | ||
| 172 | events.emit("built"); | ||
| 173 | }, | ||
| 174 | }, | ||
| 175 | ], | ||
| 176 | server: { host: "127.0.0.1", port: 0 }, | ||
| 177 | }); | ||
| 178 | await server.listen(); | ||
| 179 | await built; | ||
| 180 | const address = server.httpServer?.address(); | ||
| 181 | if (!address || typeof address === "string") throw new Error("Vite did not bind a port"); | ||
| 182 | |||
| 183 | const connected = NodeEvents.EventEmitter.once(events, "connected"); | ||
| 184 | server.ws.on("vite:client-connected", () => events.emit("connected")); | ||
| 185 | socket = new WebSocket( | ||
| 186 | `ws://127.0.0.1:${address.port}/?token=${server.config.webSocketToken}`, | ||
| 187 | "vite-hmr", | ||
| 188 | ); | ||
| 189 | socket.addEventListener("open", () => { | ||
| 190 | socket?.send( | ||
| 191 | JSON.stringify({ | ||
| 192 | type: "custom", | ||
| 193 | event: "vite:client-connected", | ||
| 194 | data: { clientId: "tailwind-test" }, | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | }); | ||
| 198 | socket.addEventListener("message", ({ data }) => { | ||
| 199 | const message: unknown = JSON.parse(String(data)); | ||
| 200 | if ( | ||
| 201 | message !== null && | ||
| 202 | typeof message === "object" && | ||
| 203 | "type" in message && | ||
| 204 | message.type === "bundled-dev-update" | ||
| 205 | ) { | ||
| 206 | events.emit("updated"); | ||
| 207 | } | ||
| 208 | }); | ||
| 209 | await connected; | ||
| 210 | const entry = await fetch(`http://127.0.0.1:${address.port}/assets/index.js`); | ||
| 211 | expect(entry.headers.get("content-type")).toContain("javascript"); | ||
| 212 | await entry.text(); | ||
| 213 | |||
| 214 | const updated = NodeEvents.EventEmitter.once(events, "updated"); | ||
| 215 | await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source.replace("13px", "137px")); | ||
| 216 | await updated; | ||
| 217 | expect(css).toContain("margin: 137px"); | ||
| 218 | } finally { | ||
| 219 | socket?.close(); | ||
| 220 | await server?.close(); | ||
| 221 | await NodeFSP.rm(root, { recursive: true, force: true }); | ||
| 222 | } | ||
| 223 | }); | ||
apps/web/src/lib/bootError.ts created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | /** Shows startup failures before React can replace the boot splash. */ | ||
| 2 | export function showBootError(error: unknown) { | ||
| 3 | console.error("T3 Code failed to start.", error); | ||
| 4 | const bootShell = document.getElementById("boot-shell"); | ||
| 5 | if (!bootShell) return; | ||
| 6 | |||
| 7 | const content = document.createElement("div"); | ||
| 8 | content.id = "boot-error"; | ||
| 9 | content.setAttribute("role", "alert"); | ||
| 10 | |||
| 11 | const message = document.createElement("p"); | ||
| 12 | message.textContent = "T3 Code could not load."; | ||
| 13 | content.append(message); | ||
| 14 | |||
| 15 | if (import.meta.env.DEV && error instanceof Error) { | ||
| 16 | const detail = document.createElement("p"); | ||
| 17 | detail.textContent = error.message; | ||
| 18 | content.append(detail); | ||
| 19 | } | ||
| 20 | |||
| 21 | const reload = document.createElement("button"); | ||
| 22 | reload.type = "button"; | ||
| 23 | reload.textContent = "Reload"; | ||
| 24 | reload.addEventListener("click", () => window.location.reload()); | ||
| 25 | content.append(reload); | ||
| 26 | bootShell.replaceChildren(content); | ||
| 27 | } | ||
apps/web/src/main.tsx+6-6| ... | @@ -56,7 +56,10 @@ const managedAuthShellModule = | ... | @@ -56,7 +56,10 @@ const managedAuthShellModule = |
| 56 | // managed-auth runtime and the initial route's split chunks, before | 56 | // managed-auth runtime and the initial route's split chunks, before |
| 57 | // rendering, so the splash holds until real UI paints instead of dropping to | 57 | // rendering, so the splash holds until real UI paints instead of dropping to |
| 58 | // a blank window while chunks download. | 58 | // a blank window while chunks download. |
| 59 | void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? null, router.load()]) | 59 | export const startup = Promise.all([ |
| 60 | managedAuthShellModule?.then((module) => module.default) ?? null, | ||
| 61 | router.load(), | ||
| 62 | ]) | ||
| 60 | .then(([ManagedAuthShell]) => { | 63 | .then(([ManagedAuthShell]) => { |
| 61 | // A route chunk failure still resolves router.load(): the error is parked in | 64 | // A route chunk failure still resolves router.load(): the error is parked in |
| 62 | // the lazy component and surfaces through the route error boundary. Skip the | 65 | // the lazy component and surfaces through the route error boundary. Skip the |
| ... | @@ -75,10 +78,7 @@ void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? nu | ... | @@ -75,10 +78,7 @@ void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? nu |
| 75 | ); | 78 | ); |
| 76 | }) | 79 | }) |
| 77 | .catch((error: unknown) => { | 80 | .catch((error: unknown) => { |
| 78 | // The auth shell chunk failed and the guarded reload is spent. Say so | 81 | // Let the bootstrap entry show the error unless a reload is already scheduled. |
| 79 | // instead of leaving the splash up forever. | ||
| 80 | if (reloadScheduled) return; | 82 | if (reloadScheduled) return; |
| 81 | console.error("T3 Code failed to load its startup chunks.", error); | 83 | throw error; |
| 82 | const bootShell = document.getElementById("boot-shell"); | ||
| 83 | if (bootShell) bootShell.textContent = "T3 Code could not load. Reload to try again."; | ||
| 84 | }); | 84 | }); |
apps/web/tsconfig.json+1| ... | @@ -27,6 +27,7 @@ | ... | @@ -27,6 +27,7 @@ |
| 27 | }, | 27 | }, |
| 28 | "include": [ | 28 | "include": [ |
| 29 | "src", | 29 | "src", |
| 30 | "vite", | ||
| 30 | "vite.config.ts", | 31 | "vite.config.ts", |
| 31 | "vercel.ts", | 32 | "vercel.ts", |
| 32 | "test", | 33 | "test", |
apps/web/vite.config.ts+2-2| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import * as NodeZlib from "node:zlib"; | 1 | import * as NodeZlib from "node:zlib"; |
| 2 | 2 | ||
| 3 | import tailwindcss from "@tailwindcss/vite"; | ||
| 4 | import react, { reactCompilerPreset } from "@vitejs/plugin-react"; | 3 | import react, { reactCompilerPreset } from "@vitejs/plugin-react"; |
| 5 | import babel from "@rolldown/plugin-babel"; | 4 | import babel from "@rolldown/plugin-babel"; |
| 6 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; | 5 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; |
| ... | @@ -13,6 +12,7 @@ import pkg from "./package.json" with { type: "json" }; | ... | @@ -13,6 +12,7 @@ import pkg from "./package.json" with { type: "json" }; |
| 13 | import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; | 12 | import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; |
| 14 | 13 | ||
| 15 | import { loadRepoEnv } from "../../scripts/lib/public-config"; | 14 | import { loadRepoEnv } from "../../scripts/lib/public-config"; |
| 15 | import { tailwindPlugins } from "./vite/tailwind"; | ||
| 16 | 16 | ||
| 17 | const repoEnv = loadRepoEnv(); | 17 | const repoEnv = loadRepoEnv(); |
| 18 | Object.assign(process.env, repoEnv); | 18 | Object.assign(process.env, repoEnv); |
| ... | @@ -169,7 +169,7 @@ export default defineConfig(() => { | ... | @@ -169,7 +169,7 @@ export default defineConfig(() => { |
| 169 | parserOpts: { plugins: ["typescript", "jsx"] }, | 169 | parserOpts: { plugins: ["typescript", "jsx"] }, |
| 170 | presets: [reactCompilerPreset()], | 170 | presets: [reactCompilerPreset()], |
| 171 | }), | 171 | }), |
| 172 | tailwindcss(), | 172 | tailwindPlugins(bundledDev), |
| 173 | ], | 173 | ], |
| 174 | optimizeDeps: { | 174 | optimizeDeps: { |
| 175 | include: [ | 175 | include: [ |
apps/web/vite/tailwind.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import tailwindcss from "@tailwindcss/vite"; | ||
| 2 | |||
| 3 | /** Adapts Tailwind's dev hooks to Vite's experimental bundled mode. */ | ||
| 4 | export function tailwindPlugins(bundledDev: boolean) { | ||
| 5 | const plugins = tailwindcss(); | ||
| 6 | if (bundledDev) { | ||
| 7 | for (const plugin of plugins) { | ||
| 8 | // This hook expects Vite ModuleNodes and a server, which Rolldown does | ||
| 9 | // not supply. Bundled dev tracks Tailwind's addWatchFile dependencies | ||
| 10 | // and rebuilds CSS when those files change without this hook. | ||
| 11 | delete plugin.hotUpdate; | ||
| 12 | } | ||
| 13 | } | ||
| 14 | return plugins; | ||
| 15 | } | ||
docs/internals/scripts.md+5| ... | @@ -27,6 +27,11 @@ authenticated. | ... | @@ -27,6 +27,11 @@ authenticated. |
| 27 | Shared runs default to Vite's bundled dev mode (`T3CODE_BUNDLED_DEV=1`): a remote browser pays a | 27 | Shared runs default to Vite's bundled dev mode (`T3CODE_BUNDLED_DEV=1`): a remote browser pays a |
| 28 | network round trip per import level in unbundled dev, which turns a cold module graph into | 28 | network round trip per import level in unbundled dev, which turns a cold module graph into |
| 29 | minutes of waterfall. Set `T3CODE_BUNDLED_DEV=0` to opt a shared run back out. | 29 | minutes of waterfall. Set `T3CODE_BUNDLED_DEV=0` to opt a shared run back out. |
| 30 | The web entry loads the app with a dynamic import so React refresh starts before shared UI | ||
| 31 | chunks run. Keep app imports out of that entry. A static import can work on the first load | ||
| 32 | and then fail on reload after Vite splits code for lazy routes. | ||
| 33 | Bundled dev uses Tailwind's watched files to rebuild CSS. Its Vite-only hot-update hook is | ||
| 34 | disabled in this mode because Rolldown does not supply the Vite server or module graph. | ||
| 30 | - `vp run dev --browser`: Auto-opens a browser. Off by default. The dev runner writes | 35 | - `vp run dev --browser`: Auto-opens a browser. Off by default. The dev runner writes |
| 31 | `T3CODE_NO_BROWSER` itself from this flag, so setting `T3CODE_NO_BROWSER=0` in your environment has | 36 | `T3CODE_NO_BROWSER` itself from this flag, so setting `T3CODE_NO_BROWSER=0` in your environment has |
| 32 | no effect; use `--browser`. | 37 | no effect; use `--browser`. |
docs/user/install.md+3| ... | @@ -18,6 +18,9 @@ npx t3@latest | ... | @@ -18,6 +18,9 @@ npx t3@latest |
| 18 | This starts the T3 Code server on your machine and opens the local web app. Use | 18 | This starts the T3 Code server on your machine and opens the local web app. Use |
| 19 | `npx t3@latest --help` for the full CLI reference. | 19 | `npx t3@latest --help` for the full CLI reference. |
| 20 | 20 | ||
| 21 | If the web or desktop app shows "T3 Code could not load", check your connection and select | ||
| 22 | **Reload** to try again. | ||
| 23 | |||
| 21 | ## Open a project in the desktop app | 24 | ## Open a project in the desktop app |
| 22 | 25 | ||
| 23 | When the T3 Code desktop app is running on the same machine, open the current directory with: | 26 | When the T3 Code desktop app is running on the same machine, open the current directory with: |