Skip to content

Commit 7a9ff8c

Browse files
authored
feat: add vite preview support (#14507)
1 parent 509bf33 commit 7a9ff8c

File tree

4 files changed

+402
-1
lines changed

4 files changed

+402
-1
lines changed

.changeset/stupid-forks-admire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-router/dev": minor
3+
---
4+
5+
feat: add `vite preview` support

integration/helpers/vite.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export const EXPRESS_SERVER = (args: {
185185
},
186186
})
187187
);
188-
const app = express();
188+
const app = express();
189189
190190
${args?.customLogic || ""}
191191
@@ -413,6 +413,28 @@ export const createDev =
413413
export const dev = createDev([reactRouterBin, "dev"]);
414414
export const customDev = createDev(["./server.mjs"]);
415415

416+
export const vitePreview = async ({
417+
cwd,
418+
port,
419+
}: {
420+
cwd: string;
421+
port: number;
422+
}) => {
423+
let nodeBin = process.argv[0];
424+
let viteBin = path.join(cwd, "node_modules", "vite", "bin", "vite.js");
425+
let proc = spawn(
426+
nodeBin,
427+
[viteBin, "preview", "--port", String(port), "--strict-port"],
428+
{
429+
cwd,
430+
stdio: "pipe",
431+
env: { NODE_ENV: "production" },
432+
},
433+
);
434+
await waitForServer(proc, { port });
435+
return () => proc.kill();
436+
};
437+
416438
// Used for testing errors thrown on build when we don't want to start and
417439
// wait for the server
418440
export const viteDevCmd = ({ cwd }: { cwd: string }) => {
@@ -450,6 +472,13 @@ type Fixtures = {
450472
port: number;
451473
cwd: string;
452474
}>;
475+
vitePreview: (
476+
files: Files,
477+
templateName?: TemplateName,
478+
) => Promise<{
479+
port: number;
480+
cwd: string;
481+
}>;
453482
wranglerPagesDev: (files: Files) => Promise<{
454483
port: number;
455484
cwd: string;
@@ -497,6 +526,18 @@ export const test = base.extend<Fixtures>({
497526
});
498527
stop?.();
499528
},
529+
vitePreview: async ({}, use) => {
530+
let stop: (() => unknown) | undefined;
531+
await use(async (files, template) => {
532+
let port = await getPort();
533+
let cwd = await createProject(await files({ port }), template);
534+
let { status } = build({ cwd });
535+
expect(status).toBe(0);
536+
stop = await vitePreview({ cwd, port });
537+
return { port, cwd };
538+
});
539+
stop?.();
540+
},
500541
// eslint-disable-next-line no-empty-pattern
501542
wranglerPagesDev: async ({}, use) => {
502543
let stop: (() => unknown) | undefined;

integration/vite-preview-test.ts

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { expect } from "@playwright/test";
2+
import dedent from "dedent";
3+
4+
import {
5+
reactRouterConfig,
6+
viteConfig,
7+
test,
8+
type Files,
9+
} from "./helpers/vite.js";
10+
11+
const tsx = dedent;
12+
13+
test.describe("Vite preview", () => {
14+
test("serves built app with vite preview", async ({ vitePreview, page }) => {
15+
const files: Files = async ({ port }) => ({
16+
"react-router.config.ts": reactRouterConfig({
17+
v8_viteEnvironmentApi: true,
18+
}),
19+
"vite.config.ts": await viteConfig.basic({
20+
port,
21+
templateName: "vite-6-template",
22+
}),
23+
"app/root.tsx": tsx`
24+
import { Links, Meta, Outlet, Scripts } from "react-router";
25+
26+
export default function Root() {
27+
return (
28+
<html lang="en">
29+
<head>
30+
<Meta />
31+
<Links />
32+
</head>
33+
<body>
34+
<div id="content">
35+
<h1>Root</h1>
36+
<Outlet />
37+
</div>
38+
<Scripts />
39+
</body>
40+
</html>
41+
);
42+
}
43+
`,
44+
"app/routes/_index.tsx": tsx`
45+
export default function IndexRoute() {
46+
return (
47+
<div id="index">
48+
<h2 data-title>Index</h2>
49+
<p data-env>Environment: production</p>
50+
</div>
51+
);
52+
}
53+
`,
54+
"app/routes/about.tsx": tsx`
55+
export default function AboutRoute() {
56+
return (
57+
<div id="about">
58+
<h2 data-title>About</h2>
59+
<p>This is the about page</p>
60+
</div>
61+
);
62+
}
63+
`,
64+
"app/routes/loader-data.tsx": tsx`
65+
import { useLoaderData } from "react-router";
66+
67+
export function loader() {
68+
return { message: "Hello from loader" };
69+
}
70+
71+
export default function LoaderDataRoute() {
72+
const { message } = useLoaderData<typeof loader>();
73+
return (
74+
<div id="loader-data">
75+
<h2 data-title>Loader Data</h2>
76+
<p data-message>{message}</p>
77+
</div>
78+
);
79+
}
80+
`,
81+
});
82+
83+
const { port } = await vitePreview(files, "vite-6-template");
84+
await page.goto(`http://localhost:${port}/`, {
85+
waitUntil: "networkidle",
86+
});
87+
88+
// Ensure no errors on page load
89+
expect(page.errors).toEqual([]);
90+
91+
await expect(page.locator("#index [data-title]")).toHaveText("Index");
92+
await expect(page.locator("#index [data-env]")).toHaveText(
93+
"Environment: production",
94+
);
95+
});
96+
97+
test("handles navigation between routes", async ({ vitePreview, page }) => {
98+
const files: Files = async ({ port }) => ({
99+
"react-router.config.ts": reactRouterConfig({
100+
v8_viteEnvironmentApi: true,
101+
}),
102+
"vite.config.ts": await viteConfig.basic({
103+
port,
104+
templateName: "vite-6-template",
105+
}),
106+
"app/root.tsx": tsx`
107+
import { Links, Meta, Outlet, Scripts, Link } from "react-router";
108+
109+
export default function Root() {
110+
return (
111+
<html lang="en">
112+
<head>
113+
<Meta />
114+
<Links />
115+
</head>
116+
<body>
117+
<div id="content">
118+
<nav>
119+
<Link to="/" data-link-home>Home</Link>
120+
<Link to="/about" data-link-about>About</Link>
121+
</nav>
122+
<Outlet />
123+
</div>
124+
<Scripts />
125+
</body>
126+
</html>
127+
);
128+
}
129+
`,
130+
"app/routes/_index.tsx": tsx`
131+
export default function IndexRoute() {
132+
return (
133+
<div id="index">
134+
<h2 data-title>Index</h2>
135+
</div>
136+
);
137+
}
138+
`,
139+
"app/routes/about.tsx": tsx`
140+
export default function AboutRoute() {
141+
return (
142+
<div id="about">
143+
<h2 data-title>About</h2>
144+
</div>
145+
);
146+
}
147+
`,
148+
});
149+
150+
const { port } = await vitePreview(files, "vite-6-template");
151+
await page.goto(`http://localhost:${port}/`, {
152+
waitUntil: "networkidle",
153+
});
154+
155+
expect(page.errors).toEqual([]);
156+
await expect(page.locator("#index [data-title]")).toHaveText("Index");
157+
158+
// Navigate to about page
159+
await page.click("[data-link-about]");
160+
await page.waitForLoadState("networkidle");
161+
162+
expect(page.errors).toEqual([]);
163+
await expect(page.locator("#about [data-title]")).toHaveText("About");
164+
165+
// Navigate back to home
166+
await page.click("[data-link-home]");
167+
await page.waitForLoadState("networkidle");
168+
169+
expect(page.errors).toEqual([]);
170+
await expect(page.locator("#index [data-title]")).toHaveText("Index");
171+
});
172+
173+
test("handles loader data correctly", async ({ vitePreview, page }) => {
174+
const files: Files = async ({ port }) => ({
175+
"react-router.config.ts": reactRouterConfig({
176+
v8_viteEnvironmentApi: true,
177+
}),
178+
"vite.config.ts": await viteConfig.basic({
179+
port,
180+
templateName: "vite-6-template",
181+
}),
182+
"app/root.tsx": tsx`
183+
import { Links, Meta, Outlet, Scripts } from "react-router";
184+
185+
export default function Root() {
186+
return (
187+
<html lang="en">
188+
<head>
189+
<Meta />
190+
<Links />
191+
</head>
192+
<body>
193+
<div id="content">
194+
<Outlet />
195+
</div>
196+
<Scripts />
197+
</body>
198+
</html>
199+
);
200+
}
201+
`,
202+
"app/routes/_index.tsx": tsx`
203+
import { useLoaderData } from "react-router";
204+
205+
export function loader() {
206+
return {
207+
message: "Hello from loader",
208+
timestamp: Date.now()
209+
};
210+
}
211+
212+
export default function IndexRoute() {
213+
const { message, timestamp } = useLoaderData<typeof loader>();
214+
return (
215+
<div id="index">
216+
<h2 data-title>Index</h2>
217+
<p data-message>{message}</p>
218+
<p data-timestamp>{timestamp}</p>
219+
</div>
220+
);
221+
}
222+
`,
223+
});
224+
225+
const { port } = await vitePreview(files, "vite-6-template");
226+
await page.goto(`http://localhost:${port}/`, {
227+
waitUntil: "networkidle",
228+
});
229+
230+
expect(page.errors).toEqual([]);
231+
await expect(page.locator("#index [data-title]")).toHaveText("Index");
232+
await expect(page.locator("#index [data-message]")).toHaveText(
233+
"Hello from loader",
234+
);
235+
236+
// Check that timestamp exists and is a number
237+
const timestampText = await page
238+
.locator("#index [data-timestamp]")
239+
.textContent();
240+
expect(timestampText).toBeTruthy();
241+
expect(Number(timestampText)).toBeGreaterThan(0);
242+
});
243+
244+
test("handles direct navigation to dynamic routes", async ({
245+
vitePreview,
246+
page,
247+
}) => {
248+
const files: Files = async ({ port }) => ({
249+
"react-router.config.ts": reactRouterConfig({
250+
v8_viteEnvironmentApi: true,
251+
}),
252+
"vite.config.ts": await viteConfig.basic({
253+
port,
254+
templateName: "vite-6-template",
255+
}),
256+
"app/root.tsx": tsx`
257+
import { Links, Meta, Outlet, Scripts } from "react-router";
258+
259+
export default function Root() {
260+
return (
261+
<html lang="en">
262+
<head>
263+
<Meta />
264+
<Links />
265+
</head>
266+
<body>
267+
<div id="content">
268+
<Outlet />
269+
</div>
270+
<Scripts />
271+
</body>
272+
</html>
273+
);
274+
}
275+
`,
276+
"app/routes/_index.tsx": tsx`
277+
export default function IndexRoute() {
278+
return <div id="index"><h2>Index</h2></div>;
279+
}
280+
`,
281+
"app/routes/products.$id.tsx": tsx`
282+
import { useLoaderData, useParams } from "react-router";
283+
284+
export function loader({ params }: { params: { id: string } }) {
285+
return {
286+
productId: params.id,
287+
};
288+
}
289+
290+
export default function ProductRoute() {
291+
const { productId } = useLoaderData<typeof loader>();
292+
return (
293+
<div id="product">
294+
<h2 data-title>Product Details</h2>
295+
<p data-id>{productId}</p>
296+
<p data-name>Product {productId}</p>
297+
</div>
298+
);
299+
}
300+
`,
301+
});
302+
303+
const { port } = await vitePreview(files, "vite-6-template");
304+
await page.goto(`http://localhost:${port}/products/123`, {
305+
waitUntil: "networkidle",
306+
});
307+
308+
expect(page.errors).toEqual([]);
309+
await expect(page.locator("#product [data-title]")).toHaveText(
310+
"Product Details",
311+
);
312+
await expect(page.locator("#product [data-id]")).toHaveText("123");
313+
await expect(page.locator("#product [data-name]")).toHaveText(
314+
"Product 123",
315+
);
316+
});
317+
});

0 commit comments

Comments
 (0)