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 connected = NodeEvents.EventEmitter.once(events, "connected");
151 const ready = NodeEvents.EventEmitter.once(events, "ready");
152 server = await createServer({
153 configFile: false,
154 root,
155 customLogger: logger,
156 resolve: {
157 alias: {
158 tailwindcss: NodeURL.fileURLToPath(
159 new URL("../node_modules/tailwindcss/index.css", import.meta.url),
160 ),
161 },
162 },
163 experimental: { bundledDev: true },
164 plugins: [
165 ...tailwindPlugins(true),
166 {
167 name: "observe-tailwind-output",
168 enforce: "pre",
169 transform(code, id) {
170 if (id.endsWith("/style.css")) css = code;
171 },
172 async generateBundle() {
173 // Keep the build pending until the socket can receive Vite's ready message.
174 await connected;
175 },
176 },
177 ],
178 server: { host: "127.0.0.1", port: 0 },
179 });
180 await server.listen();
181 const address = server.httpServer?.address();
182 if (!address || typeof address === "string") throw new Error("Vite did not bind a port");
183
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 (message !== null && typeof message === "object" && "type" in message) {
201 // generateBundle runs before Vite stores the files for HTTP requests.
202 if (
203 message.type === "full-reload" &&
204 "ifFallback" in message &&
205 message.ifFallback === true
206 ) {
207 events.emit("ready");
208 } else if (message.type === "bundled-dev-update") {
209 events.emit("updated");
210 }
211 }
212 });
213 await connected;
214 await ready;
215 const entry = await fetch(`http://127.0.0.1:${address.port}/assets/index.js`);
216 expect(entry.headers.get("content-type")).toContain("javascript");
217 await entry.text();
218
219 const updated = NodeEvents.EventEmitter.once(events, "updated");
220 await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source.replace("13px", "137px"));
221 await updated;
222 expect(css).toContain("margin: 137px");
223 } finally {
224 socket?.close();
225 await server?.close();
226 await NodeFSP.rm(root, { recursive: true, force: true });
227 }
228});