authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 18:45:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 18:45:08-07:00
log6f405370c8e552da9dcfdd6922e85789fd918340
tree2ae20c69ee579c3d26eb5ea6f879c35cd90237a1
parentdddc0bdcb2230147e207efb17df2e49dbe1bdd8c
signature Signed by PGP key B5690EEEBB952194

fix(dev): keep shared dev reloads and hot updates working (#9543)


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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { showBootError } from "./lib/bootError";
4
5class 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
28describe("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 @@
1import { 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.
5void 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.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeEvents from "node:events";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as NodeURL from "node:url";
8import * as NodeUtil from "node:util";
9
10import react from "@vitejs/plugin-react";
11import { createLogger, createServer } from "vite-plus";
12import { expect, it } from "vite-plus/test";
13
14import { tailwindPlugins } from "../vite/tailwind";
15
16const execFile = NodeUtil.promisify(NodeChildProcess.execFile);
17
18it("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";
42export 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";
102const started = Promise.withResolvers();
103globalThis.window = globalThis;
104globalThis.document = {
105 createElement: () => ({ relList: { supports: () => true } }),
106 getElementById: () => null,
107};
108globalThis.__rolldown_runtime__ = {
109 registerGraph() {},
110 registerModule() {},
111 createModuleHotContext: () => ({ accept() {} }),
112};
113globalThis.onStarted = started.resolve;
114console.error = (_message, error) => started.reject(error);
115await import("./assets/index.js");
116const element = await started.promise;
117assert.equal(element.props.children, "ready");
118assert.equal(typeof window.$RefreshReg$, "function");
119console.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
129it("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. */
2export 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, before56// managed-auth runtime and the initial route's split chunks, before
57// rendering, so the splash holds until real UI paints instead of dropping to57// 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.
59void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? null, router.load()])59export 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 in64 // 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 the65 // 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 so81 // 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 @@
1import * as NodeZlib from "node:zlib";1import * as NodeZlib from "node:zlib";
22
3import tailwindcss from "@tailwindcss/vite";
4import react, { reactCompilerPreset } from "@vitejs/plugin-react";3import react, { reactCompilerPreset } from "@vitejs/plugin-react";
5import babel from "@rolldown/plugin-babel";4import babel from "@rolldown/plugin-babel";
6import { tanstackRouter } from "@tanstack/router-plugin/vite";5import { 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" };
13import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";12import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";
1413
15import { loadRepoEnv } from "../../scripts/lib/public-config";14import { loadRepoEnv } from "../../scripts/lib/public-config";
15import { tailwindPlugins } from "./vite/tailwind";
1616
17const repoEnv = loadRepoEnv();17const repoEnv = loadRepoEnv();
18Object.assign(process.env, repoEnv);18Object.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 @@
1import tailwindcss from "@tailwindcss/vite";
2
3/** Adapts Tailwind's dev hooks to Vite's experimental bundled mode. */
4export 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 a27 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 into28 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 writes35- `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 has36 `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
18This starts the T3 Code server on your machine and opens the local web app. Use18This 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.
2020
21If 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 app24## Open a project in the desktop app
2225
23When the T3 Code desktop app is running on the same machine, open the current directory with:26When the T3 Code desktop app is running on the same machine, open the current directory with: