Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stupid-forks-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-router/dev": minor
---

feat: add `vite preview` support
37 changes: 37 additions & 0 deletions integration/helpers/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,24 @@ export const createDev =
export const dev = createDev([reactRouterBin, "dev"]);
export const customDev = createDev(["./server.mjs"]);

export const vitePreview = async ({
cwd,
port,
}: {
cwd: string;
port: number;
}) => {
let nodeBin = process.argv[0];
let viteBin = path.join(cwd, "node_modules", "vite", "bin", "vite.js");
let proc = spawn(nodeBin, [viteBin, "preview", "--port", String(port), "--strict-port"], {
cwd,
stdio: "pipe",
env: { NODE_ENV: "production" },
});
await waitForServer(proc, { port });
return () => proc.kill();
};

// Used for testing errors thrown on build when we don't want to start and
// wait for the server
export const viteDevCmd = ({ cwd }: { cwd: string }) => {
Expand Down Expand Up @@ -452,6 +470,13 @@ type Fixtures = {
port: number;
cwd: string;
}>;
vitePreview: (
files: Files,
templateName?: TemplateName,
) => Promise<{
port: number;
cwd: string;
}>;
wranglerPagesDev: (files: Files) => Promise<{
port: number;
cwd: string;
Expand Down Expand Up @@ -499,6 +524,18 @@ export const test = base.extend<Fixtures>({
});
stop?.();
},
vitePreview: async (_, use) => {
let stop: (() => unknown) | undefined;
await use(async (files, template) => {
let port = await getPort();
let cwd = await createProject(await files({ port }), template);
let { status } = build({ cwd });
expect(status).toBe(0);
stop = await vitePreview({ cwd, port });
return { port, cwd };
});
stop?.();
},
// eslint-disable-next-line no-empty-pattern
wranglerPagesDev: async ({}, use) => {
let stop: (() => unknown) | undefined;
Expand Down
317 changes: 317 additions & 0 deletions integration/vite-preview-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
import { expect } from "@playwright/test";
import dedent from "dedent";

import {
reactRouterConfig,
viteConfig,
test,
type Files,
} from "./helpers/vite.js";

const tsx = dedent;

test.describe("Vite preview", () => {
test("serves built app with vite preview", async ({ vitePreview, page }) => {
const files: Files = async ({ port }) => ({
"react-router.config.ts": reactRouterConfig({
viteEnvironmentApi: true,
}),
"vite.config.ts": await viteConfig.basic({
port,
templateName: "vite-6-template",
}),
"app/root.tsx": tsx`
import { Links, Meta, Outlet, Scripts } from "react-router";

export default function Root() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<div id="content">
<h1>Root</h1>
<Outlet />
</div>
<Scripts />
</body>
</html>
);
}
`,
"app/routes/_index.tsx": tsx`
export default function IndexRoute() {
return (
<div id="index">
<h2 data-title>Index</h2>
<p data-env>Environment: production</p>
</div>
);
}
`,
"app/routes/about.tsx": tsx`
export default function AboutRoute() {
return (
<div id="about">
<h2 data-title>About</h2>
<p>This is the about page</p>
</div>
);
}
`,
"app/routes/loader-data.tsx": tsx`
import { useLoaderData } from "react-router";

export function loader() {
return { message: "Hello from loader" };
}

export default function LoaderDataRoute() {
const { message } = useLoaderData<typeof loader>();
return (
<div id="loader-data">
<h2 data-title>Loader Data</h2>
<p data-message>{message}</p>
</div>
);
}
`,
});

const { port } = await vitePreview(files, "vite-6-template");
await page.goto(`http://localhost:${port}/`, {
waitUntil: "networkidle",
});

// Ensure no errors on page load
expect(page.errors).toEqual([]);

await expect(page.locator("#index [data-title]")).toHaveText("Index");
await expect(page.locator("#index [data-env]")).toHaveText(
"Environment: production",
);
});

test("handles navigation between routes", async ({ vitePreview, page }) => {
const files: Files = async ({ port }) => ({
"react-router.config.ts": reactRouterConfig({
viteEnvironmentApi: true,
}),
"vite.config.ts": await viteConfig.basic({
port,
templateName: "vite-6-template",
}),
"app/root.tsx": tsx`
import { Links, Meta, Outlet, Scripts, Link } from "react-router";

export default function Root() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<div id="content">
<nav>
<Link to="/" data-link-home>Home</Link>
<Link to="/about" data-link-about>About</Link>
</nav>
<Outlet />
</div>
<Scripts />
</body>
</html>
);
}
`,
"app/routes/_index.tsx": tsx`
export default function IndexRoute() {
return (
<div id="index">
<h2 data-title>Index</h2>
</div>
);
}
`,
"app/routes/about.tsx": tsx`
export default function AboutRoute() {
return (
<div id="about">
<h2 data-title>About</h2>
</div>
);
}
`,
});

const { port } = await vitePreview(files, "vite-6-template");
await page.goto(`http://localhost:${port}/`, {
waitUntil: "networkidle",
});

expect(page.errors).toEqual([]);
await expect(page.locator("#index [data-title]")).toHaveText("Index");

// Navigate to about page
await page.click("[data-link-about]");
await page.waitForLoadState("networkidle");

expect(page.errors).toEqual([]);
await expect(page.locator("#about [data-title]")).toHaveText("About");

// Navigate back to home
await page.click("[data-link-home]");
await page.waitForLoadState("networkidle");

expect(page.errors).toEqual([]);
await expect(page.locator("#index [data-title]")).toHaveText("Index");
});

test("handles loader data correctly", async ({ vitePreview, page }) => {
const files: Files = async ({ port }) => ({
"react-router.config.ts": reactRouterConfig({
viteEnvironmentApi: true,
}),
"vite.config.ts": await viteConfig.basic({
port,
templateName: "vite-6-template",
}),
"app/root.tsx": tsx`
import { Links, Meta, Outlet, Scripts } from "react-router";

export default function Root() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<div id="content">
<Outlet />
</div>
<Scripts />
</body>
</html>
);
}
`,
"app/routes/_index.tsx": tsx`
import { useLoaderData } from "react-router";

export function loader() {
return {
message: "Hello from loader",
timestamp: Date.now()
};
}

export default function IndexRoute() {
const { message, timestamp } = useLoaderData<typeof loader>();
return (
<div id="index">
<h2 data-title>Index</h2>
<p data-message>{message}</p>
<p data-timestamp>{timestamp}</p>
</div>
);
}
`,
});

const { port } = await vitePreview(files, "vite-6-template");
await page.goto(`http://localhost:${port}/`, {
waitUntil: "networkidle",
});

expect(page.errors).toEqual([]);
await expect(page.locator("#index [data-title]")).toHaveText("Index");
await expect(page.locator("#index [data-message]")).toHaveText(
"Hello from loader",
);

// Check that timestamp exists and is a number
const timestampText = await page
.locator("#index [data-timestamp]")
.textContent();
expect(timestampText).toBeTruthy();
expect(Number(timestampText)).toBeGreaterThan(0);
});

test("handles direct navigation to dynamic routes", async ({
vitePreview,
page,
}) => {
const files: Files = async ({ port }) => ({
"react-router.config.ts": reactRouterConfig({
viteEnvironmentApi: true,
}),
"vite.config.ts": await viteConfig.basic({
port,
templateName: "vite-6-template",
}),
"app/root.tsx": tsx`
import { Links, Meta, Outlet, Scripts } from "react-router";

export default function Root() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<div id="content">
<Outlet />
</div>
<Scripts />
</body>
</html>
);
}
`,
"app/routes/_index.tsx": tsx`
export default function IndexRoute() {
return <div id="index"><h2>Index</h2></div>;
}
`,
"app/routes/products.$id.tsx": tsx`
import { useLoaderData, useParams } from "react-router";

export function loader({ params }: { params: { id: string } }) {
return {
productId: params.id,
};
}

export default function ProductRoute() {
const { productId } = useLoaderData<typeof loader>();
return (
<div id="product">
<h2 data-title>Product Details</h2>
<p data-id>{productId}</p>
<p data-name>Product {productId}</p>
</div>
);
}
`,
});

const { port } = await vitePreview(files, "vite-6-template");
await page.goto(`http://localhost:${port}/products/123`, {
waitUntil: "networkidle",
});

expect(page.errors).toEqual([]);
await expect(page.locator("#product [data-title]")).toHaveText(
"Product Details",
);
await expect(page.locator("#product [data-id]")).toHaveText("123");
await expect(page.locator("#product [data-name]")).toHaveText(
"Product 123",
);
});
});
Loading
Loading