diff --git a/.changeset/stupid-forks-admire.md b/.changeset/stupid-forks-admire.md new file mode 100644 index 0000000000..e3726eabca --- /dev/null +++ b/.changeset/stupid-forks-admire.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": minor +--- + +feat: add `vite preview` support diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index c4ab197abd..e964ea153b 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -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 }) => { @@ -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; @@ -499,6 +524,18 @@ export const test = base.extend({ }); 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; diff --git a/integration/vite-preview-test.ts b/integration/vite-preview-test.ts new file mode 100644 index 0000000000..0727814be8 --- /dev/null +++ b/integration/vite-preview-test.ts @@ -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 ( + + + + + + +
+

Root

+ +
+ + + + ); + } + `, + "app/routes/_index.tsx": tsx` + export default function IndexRoute() { + return ( +
+

Index

+

Environment: production

+
+ ); + } + `, + "app/routes/about.tsx": tsx` + export default function AboutRoute() { + return ( +
+

About

+

This is the about page

+
+ ); + } + `, + "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(); + return ( +
+

Loader Data

+

{message}

+
+ ); + } + `, + }); + + 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 ( + + + + + + +
+ + +
+ + + + ); + } + `, + "app/routes/_index.tsx": tsx` + export default function IndexRoute() { + return ( +
+

Index

+
+ ); + } + `, + "app/routes/about.tsx": tsx` + export default function AboutRoute() { + return ( +
+

About

+
+ ); + } + `, + }); + + 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 ( + + + + + + +
+ +
+ + + + ); + } + `, + "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(); + return ( +
+

Index

+

{message}

+

{timestamp}

+
+ ); + } + `, + }); + + 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 ( + + + + + + +
+ +
+ + + + ); + } + `, + "app/routes/_index.tsx": tsx` + export default function IndexRoute() { + return

Index

; + } + `, + "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(); + return ( +
+

Product Details

+

{productId}

+

Product {productId}

+
+ ); + } + `, + }); + + 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", + ); + }); +}); diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 27a5a22cae..e22755e6e3 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -1688,6 +1688,44 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { } }; }, + configurePreviewServer(previewServer) { + return () => { + // Handle SSR requests in preview mode using the built server bundle + previewServer.middlewares.use(async (req, res, next) => { + try { + let serverBuildDirectory = getServerBuildDirectory( + ctx.reactRouterConfig, + ); + let serverBuildFile = path.resolve( + serverBuildDirectory, + "index.js", + ); + + // Import the built server bundle using dynamic import + // Need to add a cache-busting query parameter to avoid module caching + let build = (await import( + url.pathToFileURL(serverBuildFile).href + )) as ServerBuild; + + let handler = createRequestHandler(build, "production"); + let nodeHandler: NodeRequestHandler = async ( + nodeReq, + nodeRes, + ) => { + let req = fromNodeRequest(nodeReq, nodeRes); + let res = await handler( + req, + await reactRouterDevLoadContext(req), + ); + await sendResponse(nodeRes, res); + }; + await nodeHandler(req, res); + } catch (error) { + next(error); + } + }); + }; + }, writeBundle: { // After the SSR build is finished, we inspect the Vite manifest for // the SSR build and move server-only assets to client assets directory