From 1b97af6f8046ff2f6cd9e8d8931001ba8b448ebc Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 19 Aug 2025 17:38:12 +1000 Subject: [PATCH 1/6] Fix FOUC in RSC Framework Mode in dev --- .../helpers/rsc-vite-framework/package.json | 3 + integration/helpers/vite.ts | 24 ++ integration/vite-css-test.ts | 277 ++++++++++++------ packages/react-router/lib/rsc/server.ssr.tsx | 9 +- pnpm-lock.yaml | 6 + 5 files changed, 231 insertions(+), 88 deletions(-) diff --git a/integration/helpers/rsc-vite-framework/package.json b/integration/helpers/rsc-vite-framework/package.json index 52d123e207..03488c7e3b 100644 --- a/integration/helpers/rsc-vite-framework/package.json +++ b/integration/helpers/rsc-vite-framework/package.json @@ -2,6 +2,7 @@ "name": "integration-rsc-vite-framework", "version": "0.0.0", "private": true, + "sideEffects": false, "type": "module", "scripts": { "dev": "vite", @@ -17,6 +18,8 @@ "@types/node": "^22.13.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@vanilla-extract/css": "^1.17.4", + "@vanilla-extract/vite-plugin": "^5.1.1", "@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-rsc": "0.4.11", "cross-env": "^7.0.3", diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 1a5a962006..f6468c3b02 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -77,8 +77,10 @@ type ViteConfigBuildArgs = { type ViteConfigBaseArgs = { templateName?: TemplateName; + base?: string; envDir?: string; mdx?: boolean; + vanillaExtract?: boolean; }; type ViteConfigArgs = ( @@ -140,15 +142,18 @@ export const viteConfig = { ].join("\n") } ${args.mdx ? 'import mdx from "@mdx-js/rollup";' : ""} + ${args.vanillaExtract ? 'import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";' : ""} import { envOnlyMacros } from "vite-env-only"; import tsconfigPaths from "vite-tsconfig-paths"; export default async () => ({ ${args.port ? await viteConfig.server(args) : ""} ${viteConfig.build(args)} + ${args.base ? `base: "${args.base}",` : ""} envDir: ${args.envDir ? `"${args.envDir}"` : "undefined"}, plugins: [ ${args.mdx ? "mdx()," : ""} + ${args.vanillaExtract ? "vanillaExtractPlugin({ emitCssInSsr: true })," : ""} reactRouter(), envOnlyMacros(), tsconfigPaths() @@ -331,6 +336,25 @@ export const reactRouterServe = async ({ return () => serveProc.kill(); }; +export const runStartScript = async ({ + cwd, + port, + basename, +}: { + cwd: string; + port: number; + basename?: string; +}) => { + let nodeBin = process.argv[0]; + let proc = spawn(nodeBin, ["start.js"], { + cwd, + stdio: "pipe", + env: { NODE_ENV: "production", PORT: port.toFixed(0) }, + }); + await waitForServer(proc, { port, basename }); + return () => proc.kill(); +}; + export const wranglerPagesDev = async ({ cwd, port, diff --git a/integration/vite-css-test.ts b/integration/vite-css-test.ts index 13c7e3a03a..29c8fc1e23 100644 --- a/integration/vite-css-test.ts +++ b/integration/vite-css-test.ts @@ -1,19 +1,20 @@ import type { Page } from "@playwright/test"; import { test, expect } from "@playwright/test"; import getPort from "get-port"; -import dedent from "dedent"; import { createProject, createEditor, dev, build, + runStartScript, reactRouterServe, customDev, EXPRESS_SERVER, reactRouterConfig, viteConfig, viteMajorTemplates, + type TemplateName, } from "./helpers/vite.js"; const js = String.raw; @@ -22,7 +23,18 @@ const css = String.raw; const PADDING = "20px"; const NEW_PADDING = "30px"; -const files = { +const fixtures = [ + ...viteMajorTemplates, + { + templateName: "rsc-vite-framework", + templateDisplayName: "RSC Vite Framework", + }, +] as const satisfies ReadonlyArray<{ + templateName: TemplateName; + templateDisplayName: string; +}>; + +const files = ({ templateName }: { templateName: TemplateName }) => ({ "postcss.config.js": js` export default ({ plugins: [ @@ -44,22 +56,27 @@ const files = { ], }); `, - "app/entry.client.tsx": js` - import "./entry.client.css"; - - import { HydratedRouter } from "react-router/dom"; - import { startTransition, StrictMode } from "react"; - import { hydrateRoot } from "react-dom/client"; - - startTransition(() => { - hydrateRoot( - document, - - - - ); - }); - `, + // RSC Framework mode doesn't support custom entries yet + ...(!templateName.includes("rsc") + ? { + "app/entry.client.tsx": js` + import "./entry.client.css"; + + import { HydratedRouter } from "react-router/dom"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + } + : {}), "app/root.tsx": js` import { Links, Meta, Outlet, Scripts } from "react-router"; @@ -125,6 +142,11 @@ const files = { import "../styles-vanilla-global.css"; import * as stylesVanillaLocal from "../styles-vanilla-local.css"; + // Workaround for "Generated an empty chunk" warnings in RSC Framework Mode + export function loader() { + return null; + } + export function links() { return [{ rel: "stylesheet", href: postcssLinkedStyles }]; } @@ -150,35 +172,46 @@ const files = { ); } `, -}; - -const VITE_CONFIG = async ({ - port, - base, - cssCodeSplit, -}: { - port: number; - base?: string; - cssCodeSplit?: boolean; -}) => dedent` - import { reactRouter } from "@react-router/dev/vite"; - import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; - - export default async () => ({ - ${await viteConfig.server({ port })} - ${viteConfig.build({ cssCodeSplit })} - ${base ? `base: "${base}",` : ""} - plugins: [ - reactRouter(), - vanillaExtractPlugin({ - emitCssInSsr: true, - }), - ], - }); -`; + ...(templateName.includes("rsc") + ? { + "app/routes/server-component-route.tsx": js` + import "../styles-bundled.css"; + import postcssLinkedStyles from "../styles-postcss-linked.css?url"; + import cssModulesStyles from "../styles.module.css"; + import "../styles-vanilla-global.css"; + import * as stylesVanillaLocal from "../styles-vanilla-local.css"; + + export function links() { + return [{ rel: "stylesheet", href: postcssLinkedStyles }]; + } + + export function ServerComponent() { + return ( + <> + +
+
+
+
+
+
+

CSS test

+
+
+
+
+
+
+ + ); + } + `, + } + : {}), +}); test.describe("Vite CSS", () => { - viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => { + fixtures.forEach(({ templateName, templateDisplayName }) => { test.describe(templateDisplayName, () => { test.describe("vite dev", async () => { let port: number; @@ -190,10 +223,14 @@ test.describe("Vite CSS", () => { cwd = await createProject( { "react-router.config.ts": reactRouterConfig({ - viteEnvironmentApi: templateName === "vite-6-template", + viteEnvironmentApi: templateName !== "vite-5-template", + }), + "vite.config.ts": await viteConfig.basic({ + port, + templateName, + vanillaExtract: true, }), - "vite.config.ts": await VITE_CONFIG({ port }), - ...files, + ...files({ templateName }), }, templateName, ); @@ -204,20 +241,24 @@ test.describe("Vite CSS", () => { test.describe(() => { test.use({ javaScriptEnabled: false }); test("without JS", async ({ page }) => { - await pageLoadWorkflow({ page, port }); + await pageLoadWorkflow({ page, port, templateName }); }); }); test.describe(() => { test.use({ javaScriptEnabled: true }); test("with JS", async ({ page }) => { - await pageLoadWorkflow({ page, port }); - await hmrWorkflow({ page, port, cwd }); + await pageLoadWorkflow({ page, port, templateName }); + await hmrWorkflow({ page, port, cwd, templateName }); }); }); }); test.describe("vite dev with custom base", async () => { + test.fixme( + templateName.includes("rsc"), + "RSC Framework mode doesn't support basename yet", + ); let port: number; let cwd: string; let stop: () => void; @@ -231,8 +272,13 @@ test.describe("Vite CSS", () => { viteEnvironmentApi: templateName === "vite-6-template", basename: base, }), - "vite.config.ts": await VITE_CONFIG({ port, base }), - ...files, + "vite.config.ts": await viteConfig.basic({ + port, + base, + templateName, + vanillaExtract: true, + }), + ...files({ templateName }), }, templateName, ); @@ -243,20 +289,25 @@ test.describe("Vite CSS", () => { test.describe(() => { test.use({ javaScriptEnabled: false }); test("without JS", async ({ page }) => { - await pageLoadWorkflow({ page, port, base }); + await pageLoadWorkflow({ page, port, base, templateName }); }); }); test.describe(() => { test.use({ javaScriptEnabled: true }); test("with JS", async ({ page }) => { - await pageLoadWorkflow({ page, port, base }); - await hmrWorkflow({ page, port, cwd, base }); + await pageLoadWorkflow({ page, port, base, templateName }); + await hmrWorkflow({ page, port, cwd, base, templateName }); }); }); }); test.describe("express", async () => { + test.fixme( + templateName.includes("rsc"), + "RSC Framework mode doesn't support Vite middleware mode yet", + ); + let port: number; let cwd: string; let stop: () => void; @@ -265,9 +316,16 @@ test.describe("Vite CSS", () => { port = await getPort(); cwd = await createProject( { - "vite.config.ts": await VITE_CONFIG({ port }), + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi: templateName !== "vite-5-template", + }), + "vite.config.ts": await viteConfig.basic({ + port, + templateName, + vanillaExtract: true, + }), "server.mjs": EXPRESS_SERVER({ port }), - ...files, + ...files({ templateName }), }, templateName, ); @@ -278,15 +336,15 @@ test.describe("Vite CSS", () => { test.describe(() => { test.use({ javaScriptEnabled: false }); test("without JS", async ({ page }) => { - await pageLoadWorkflow({ page, port }); + await pageLoadWorkflow({ page, port, templateName }); }); }); test.describe(() => { test.use({ javaScriptEnabled: true }); test("with JS", async ({ page }) => { - await pageLoadWorkflow({ page, port }); - await hmrWorkflow({ page, port, cwd }); + await pageLoadWorkflow({ page, port, templateName }); + await hmrWorkflow({ page, port, cwd, templateName }); }); }); }); @@ -300,8 +358,15 @@ test.describe("Vite CSS", () => { port = await getPort(); cwd = await createProject( { - "vite.config.ts": await VITE_CONFIG({ port }), - ...files, + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi: templateName !== "vite-5-template", + }), + "vite.config.ts": await viteConfig.basic({ + port, + templateName, + vanillaExtract: true, + }), + ...files({ templateName }), }, templateName, ); @@ -321,28 +386,43 @@ test.describe("Vite CSS", () => { VITE_CJS_IGNORE_WARNING: "true", }, }); - expect(stderr.toString()).toBeFalsy(); + let stderrString = stderr.toString(); + if (templateName.includes("rsc")) { + // In RSC builds, the same assets can be generated multiple times + stderrString = stderrString.replace( + /The emitted file ".*?" overwrites a previously emitted file of the same name\.\n?/g, + "", + ); + } + expect(stderrString).toBeFalsy(); expect(status).toBe(0); - stop = await reactRouterServe({ cwd, port }); + stop = templateName.includes("rsc") + ? await runStartScript({ cwd, port }) + : await reactRouterServe({ cwd, port }); }); test.afterAll(() => stop()); test.describe(() => { test.use({ javaScriptEnabled: false }); test("without JS", async ({ page }) => { - await pageLoadWorkflow({ page, port }); + await pageLoadWorkflow({ page, port, templateName }); }); }); test.describe(() => { test.use({ javaScriptEnabled: true }); test("with JS", async ({ page }) => { - await pageLoadWorkflow({ page, port }); + await pageLoadWorkflow({ page, port, templateName }); }); }); }); test.describe("vite build with CSS code splitting disabled", async () => { + test.fixme( + templateName.includes("rsc"), + "RSC Framework mode doesn't support disabling CSS code splitting yet (likely due to @vitejs/plugin-rsc)", + ); + let port: number; let cwd: string; let stop: () => void; @@ -351,11 +431,16 @@ test.describe("Vite CSS", () => { port = await getPort(); cwd = await createProject( { - "vite.config.ts": await VITE_CONFIG({ + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi: templateName !== "vite-5-template", + }), + "vite.config.ts": await viteConfig.basic({ port, + templateName, cssCodeSplit: false, + vanillaExtract: true, }), - ...files, + ...files({ templateName }), }, templateName, ); @@ -377,21 +462,23 @@ test.describe("Vite CSS", () => { }); expect(stderr.toString()).toBeFalsy(); expect(status).toBe(0); - stop = await reactRouterServe({ cwd, port }); + stop = templateName.includes("rsc") + ? await runStartScript({ cwd, port }) + : await reactRouterServe({ cwd, port }); }); test.afterAll(() => stop()); test.describe(() => { test.use({ javaScriptEnabled: false }); test("without JS", async ({ page }) => { - await pageLoadWorkflow({ page, port }); + await pageLoadWorkflow({ page, port, templateName }); }); }); test.describe(() => { test.use({ javaScriptEnabled: true }); test("with JS", async ({ page }) => { - await pageLoadWorkflow({ page, port }); + await pageLoadWorkflow({ page, port, templateName }); }); }); }); @@ -403,30 +490,39 @@ async function pageLoadWorkflow({ page, port, base, + templateName, }: { page: Page; port: number; base?: string; + templateName: TemplateName; }) { let pageErrors: Error[] = []; page.on("pageerror", (error) => pageErrors.push(error)); - await page.goto(`http://localhost:${port}${base ?? "/"}`, { - waitUntil: "networkidle", - }); + const paths = [""]; + if (templateName.includes("rsc")) { + paths.push("server-component-route"); + } - await Promise.all( - [ - "#css-bundled", - "#css-postcss-linked", - "#css-modules", - "#css-vanilla-global", - "#css-vanilla-local", - ].map( - async (selector) => - await expect(page.locator(selector)).toHaveCSS("padding", PADDING), - ), - ); + for (const path of paths) { + await page.goto(`http://localhost:${port}${base ?? "/"}${path}`, { + waitUntil: "networkidle", + }); + + await Promise.all( + [ + "#css-bundled", + "#css-postcss-linked", + "#css-modules", + "#css-vanilla-global", + "#css-vanilla-local", + ].map( + async (selector) => + await expect(page.locator(selector)).toHaveCSS("padding", PADDING), + ), + ); + } } async function hmrWorkflow({ @@ -434,12 +530,19 @@ async function hmrWorkflow({ cwd, port, base, + templateName, }: { page: Page; cwd: string; port: number; base?: string; + templateName: TemplateName; }) { + if (templateName.includes("rsc")) { + // TODO: Fix CSS HMR support in RSC Framework mode + return; + } + let pageErrors: Error[] = []; page.on("pageerror", (error) => pageErrors.push(error)); diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index f7a20738e4..47bb43df09 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -112,6 +112,8 @@ export async function routeRSCServerRequest({ throw new Error("Missing body in server response"); } + const detectRedirectResponse = serverResponse.clone(); + let serverResponseB: Response | null = null; if (hydrate) { serverResponseB = serverResponse.clone(); @@ -126,7 +128,12 @@ export async function routeRSCServerRequest({ }; try { - const payload = await getPayload(); + if (!detectRedirectResponse.body) { + throw new Error("Failed to clone server response"); + } + const payload = (await createFromReadableStream( + detectRedirectResponse.body, + )) as RSCPayload; if ( serverResponse.status === SINGLE_FETCH_REDIRECT_STATUS && payload.type === "redirect" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0b8091100..3815f2f0b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -563,6 +563,12 @@ importers: '@types/react-dom': specifier: ^18.2.7 version: 18.2.7 + '@vanilla-extract/css': + specifier: ^1.17.4 + version: 1.17.4(babel-plugin-macros@3.1.0) + '@vanilla-extract/vite-plugin': + specifier: ^5.1.1 + version: 5.1.1(@types/node@22.14.0)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))(yaml@2.8.0) '@vitejs/plugin-react': specifier: ^4.5.2 version: 4.5.2(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0)) From e674a6a5f87f4cb23abb069a343787c16382013b Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 20 Aug 2025 14:12:31 +1000 Subject: [PATCH 2/6] Inject `import.meta.viteRsc.loadCss()` into route server component --- .../react-router-dev/vite/rsc/virtual-route-modules.ts | 9 ++++++++- packages/react-router/lib/rsc/server.ssr.tsx | 9 +-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts index e3785edf58..f46aaecaca 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -94,7 +94,14 @@ async function createVirtualRouteModuleCode({ if (isClientNonComponentExport(staticExport)) { code += `export { ${staticExport} } from "${clientModuleId}";\n`; } else if (staticExport === "ServerComponent") { - code += `export { ServerComponent as default } from "${serverModuleId}";\n`; + code += `import React from "react";\n`; + code += `import { ServerComponent } from "${serverModuleId}";\n`; + code += `export default function ServerComponentWithCss() {`; + code += ` return React.createElement(React.Fragment, null, [`; + code += ` import.meta.viteRsc.loadCss(),`; + code += ` React.createElement(ServerComponent, null),`; + code += ` ]);`; + code += `}`; } else { code += `export { ${staticExport} } from "${serverModuleId}";\n`; } diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 47bb43df09..f7a20738e4 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -112,8 +112,6 @@ export async function routeRSCServerRequest({ throw new Error("Missing body in server response"); } - const detectRedirectResponse = serverResponse.clone(); - let serverResponseB: Response | null = null; if (hydrate) { serverResponseB = serverResponse.clone(); @@ -128,12 +126,7 @@ export async function routeRSCServerRequest({ }; try { - if (!detectRedirectResponse.body) { - throw new Error("Failed to clone server response"); - } - const payload = (await createFromReadableStream( - detectRedirectResponse.body, - )) as RSCPayload; + const payload = await getPayload(); if ( serverResponse.status === SINGLE_FETCH_REDIRECT_STATUS && payload.type === "redirect" From 90abfe6c4daeaf3fbc0c2a114fd4a0f62c5f66ca Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 20 Aug 2025 15:01:31 +1000 Subject: [PATCH 3/6] Ensure server first route CSS test can fail --- integration/vite-css-test.ts | 277 +++++++++++++++++------------------ 1 file changed, 133 insertions(+), 144 deletions(-) diff --git a/integration/vite-css-test.ts b/integration/vite-css-test.ts index 29c8fc1e23..dbc1191533 100644 --- a/integration/vite-css-test.ts +++ b/integration/vite-css-test.ts @@ -34,6 +34,14 @@ const fixtures = [ templateDisplayName: string; }>; +type RouteBasePath = "css" | "rsc-server-first-css"; +const getRouteBasePaths = (templateName: TemplateName) => { + if (templateName.includes("rsc")) { + return ["css", "rsc-server-first-css"] as const satisfies RouteBasePath[]; + } + return ["css"] as const satisfies RouteBasePath[]; +}; + const files = ({ templateName }: { templateName: TemplateName }) => ({ "postcss.config.js": js` export default ({ @@ -75,6 +83,12 @@ const files = ({ templateName }: { templateName: TemplateName }) => ({ ); }); `, + "app/entry.client.css": css` + .entry-client { + background: pink; + padding: ${PADDING}; + } + `, } : {}), "app/root.tsx": js` @@ -95,105 +109,72 @@ const files = ({ templateName }: { templateName: TemplateName }) => ({ ); } `, - "app/entry.client.css": css` - .entry-client { - background: pink; - padding: ${PADDING}; - } - `, - "app/styles-bundled.css": css` - .index_bundled { - background: papayawhip; - padding: ${PADDING}; - } - `, - "app/styles-postcss-linked.css": css` - .index_postcss_linked { - background: salmon; - padding: PADDING_INJECTED_VIA_POSTCSS; - } - `, - "app/styles.module.css": css` - .index { - background: peachpuff; - padding: ${PADDING}; - } - `, - "app/styles-vanilla-global.css.ts": js` - import { createVar, globalStyle } from "@vanilla-extract/css"; - - globalStyle(".index_vanilla_global", { - background: "lightgreen", - padding: "${PADDING}", - }); - `, - "app/styles-vanilla-local.css.ts": js` - import { style } from "@vanilla-extract/css"; - - export const index = style({ - background: "lightblue", - padding: "${PADDING}", - }); - `, - "app/routes/_index.tsx": js` - import "../styles-bundled.css"; - import postcssLinkedStyles from "../styles-postcss-linked.css?url"; - import cssModulesStyles from "../styles.module.css"; - import "../styles-vanilla-global.css"; - import * as stylesVanillaLocal from "../styles-vanilla-local.css"; - - // Workaround for "Generated an empty chunk" warnings in RSC Framework Mode - export function loader() { - return null; - } + ...Object.assign( + {}, + ...getRouteBasePaths(templateName).map((routeBasePath) => { + const isServerFirstRoute = routeBasePath === "rsc-server-first-css"; + const exportName = isServerFirstRoute ? "ServerComponent" : "default"; + + return { + [`app/routes/${routeBasePath}/styles-bundled.css`]: css` + .${routeBasePath}-bundled { + background: papayawhip; + padding: ${PADDING}; + } + `, + [`app/routes/${routeBasePath}/styles-postcss-linked.css`]: css` + .${routeBasePath}-postcss-linked { + background: salmon; + padding: PADDING_INJECTED_VIA_POSTCSS; + } + `, + [`app/routes/${routeBasePath}/styles.module.css`]: css` + .index { + background: peachpuff; + padding: ${PADDING}; + } + `, + [`app/routes/${routeBasePath}/styles-vanilla-global.css.ts`]: js` + import { createVar, globalStyle } from "@vanilla-extract/css"; - export function links() { - return [{ rel: "stylesheet", href: postcssLinkedStyles }]; - } + globalStyle(".${routeBasePath}-vanilla-global", { + background: "lightgreen", + padding: "${PADDING}", + }); + `, + [`app/routes/${routeBasePath}/styles-vanilla-local.css.ts`]: js` + import { style } from "@vanilla-extract/css"; - export default function IndexRoute() { - return ( - <> - -
-
-
-
-
-
-

CSS test

-
-
-
-
-
-
- - ); - } - `, - ...(templateName.includes("rsc") - ? { - "app/routes/server-component-route.tsx": js` - import "../styles-bundled.css"; - import postcssLinkedStyles from "../styles-postcss-linked.css?url"; - import cssModulesStyles from "../styles.module.css"; - import "../styles-vanilla-global.css"; - import * as stylesVanillaLocal from "../styles-vanilla-local.css"; + export const index = style({ + background: "lightblue", + padding: "${PADDING}", + }); + `, + [`app/routes/${routeBasePath}/route.tsx`]: js` + import "./styles-bundled.css"; + import postcssLinkedStyles from "./styles-postcss-linked.css?url"; + import cssModulesStyles from "./styles.module.css"; + import "./styles-vanilla-global.css"; + import * as stylesVanillaLocal from "./styles-vanilla-local.css"; + + // Workaround for "Generated an empty chunk" warnings in RSC Framework Mode + export function loader() { + return null; + } export function links() { return [{ rel: "stylesheet", href: postcssLinkedStyles }]; } - export function ServerComponent() { + function TestRoute() { return ( <>
-
-
-
+
+
+

CSS test

@@ -205,9 +186,12 @@ const files = ({ templateName }: { templateName: TemplateName }) => ({ ); } + + export ${exportName === "default" ? "default" : `const ${exportName} =`} TestRoute; `, - } - : {}), + }; + }), + ), }); test.describe("Vite CSS", () => { @@ -497,16 +481,11 @@ async function pageLoadWorkflow({ base?: string; templateName: TemplateName; }) { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + for (const routeBase of getRouteBasePaths(templateName)) { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - const paths = [""]; - if (templateName.includes("rsc")) { - paths.push("server-component-route"); - } - - for (const path of paths) { - await page.goto(`http://localhost:${port}${base ?? "/"}${path}`, { + await page.goto(`http://localhost:${port}${base ?? "/"}${routeBase}`, { waitUntil: "networkidle", }); @@ -543,54 +522,64 @@ async function hmrWorkflow({ return; } - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + for (const routeBase of getRouteBasePaths(templateName)) { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - await page.goto(`http://localhost:${port}${base ?? "/"}`, { - waitUntil: "networkidle", - }); + await page.goto(`http://localhost:${port}${base ?? "/"}${routeBase}`, { + waitUntil: "networkidle", + }); + + let input = page.locator("input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + await expect(input).toHaveValue("stateful"); + + let edit = createEditor(cwd); + let modifyCss = (contents: string) => + contents + .replace(PADDING, NEW_PADDING) + .replace( + "PADDING_INJECTED_VIA_POSTCSS", + "NEW_PADDING_INJECTED_VIA_POSTCSS", + ); - let input = page.locator("input"); - await expect(input).toBeVisible(); - await input.type("stateful"); - await expect(input).toHaveValue("stateful"); - - let edit = createEditor(cwd); - let modifyCss = (contents: string) => - contents - .replace(PADDING, NEW_PADDING) - .replace( - "PADDING_INJECTED_VIA_POSTCSS", - "NEW_PADDING_INJECTED_VIA_POSTCSS", + await Promise.all([ + edit(`app/routes/${routeBase}/styles-bundled.css`, modifyCss), + edit(`app/routes/${routeBase}/styles.module.css`, modifyCss), + edit(`app/routes/${routeBase}/styles-vanilla-global.css.ts`, modifyCss), + edit(`app/routes/${routeBase}/styles-vanilla-local.css.ts`, modifyCss), + edit(`app/routes/${routeBase}/styles-postcss-linked.css`, modifyCss), + ]); + + await Promise.all( + [ + "#css-bundled", + "#css-postcss-linked", + "#css-modules", + "#css-vanilla-global", + "#css-vanilla-local", + ].map( + async (selector) => + await expect(page.locator(selector)).toHaveCSS( + "padding", + NEW_PADDING, + ), + ), + ); + + // Ensure CSS updates were handled by HMR + await expect(input).toHaveValue("stateful"); + + if (routeBase === "css") { + // The following change triggers a full page reload, so we check it after all the checks for HMR state preservation + await edit("app/entry.client.css", modifyCss); + await expect(page.locator("#entry-client")).toHaveCSS( + "padding", + NEW_PADDING, ); + } - await Promise.all([ - edit("app/styles-bundled.css", modifyCss), - edit("app/styles.module.css", modifyCss), - edit("app/styles-vanilla-global.css.ts", modifyCss), - edit("app/styles-vanilla-local.css.ts", modifyCss), - edit("app/styles-postcss-linked.css", modifyCss), - ]); - - await Promise.all( - [ - "#css-bundled", - "#css-postcss-linked", - "#css-modules", - "#css-vanilla-global", - "#css-vanilla-local", - ].map( - async (selector) => - await expect(page.locator(selector)).toHaveCSS("padding", NEW_PADDING), - ), - ); - - // Ensure CSS updates were handled by HMR - await expect(input).toHaveValue("stateful"); - - // The following change triggers a full page reload, so we check it after all the checks for HMR state preservation - await edit("app/entry.client.css", modifyCss); - await expect(page.locator("#entry-client")).toHaveCSS("padding", NEW_PADDING); - - expect(pageErrors).toEqual([]); + expect(pageErrors).toEqual([]); + } } From 14b13b3ca7a196475f1ce5e4d221b1970da5aead Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 20 Aug 2025 15:07:54 +1000 Subject: [PATCH 4/6] Pass props to wrapped server component --- packages/react-router-dev/vite/rsc/virtual-route-modules.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts index f46aaecaca..92e53f835d 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -96,10 +96,10 @@ async function createVirtualRouteModuleCode({ } else if (staticExport === "ServerComponent") { code += `import React from "react";\n`; code += `import { ServerComponent } from "${serverModuleId}";\n`; - code += `export default function ServerComponentWithCss() {`; + code += `export default function ServerComponentWithCss(props) {`; code += ` return React.createElement(React.Fragment, null, [`; code += ` import.meta.viteRsc.loadCss(),`; - code += ` React.createElement(ServerComponent, null),`; + code += ` React.createElement(ServerComponent, props),`; code += ` ]);`; code += `}`; } else { From 6813ad773b5146f0e5d00b554b54765d8b0dfe67 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 20 Aug 2025 16:36:10 +1000 Subject: [PATCH 5/6] Handle all component exports --- .../vite/rsc/virtual-route-modules.ts | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts index 92e53f835d..395debd7e8 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -3,21 +3,44 @@ import * as babel from "../babel"; import { parse as esModuleLexer } from "es-module-lexer"; import { removeExports } from "../remove-exports"; +const SERVER_ONLY_COMPONENT_EXPORTS = ["ServerComponent"] as const; + const SERVER_ONLY_ROUTE_EXPORTS = [ + ...SERVER_ONLY_COMPONENT_EXPORTS, "loader", "action", "unstable_middleware", "headers", - "ServerComponent", ] as const; -const COMPONENT_EXPORTS = [ - "default", +const COMMON_COMPONENT_EXPORTS = [ "ErrorBoundary", "HydrateFallback", "Layout", ] as const; +const SERVER_FIRST_COMPONENT_EXPORTS = [ + ...COMMON_COMPONENT_EXPORTS, + ...SERVER_ONLY_COMPONENT_EXPORTS, +] as const; +type ServerFirstComponentExport = + (typeof SERVER_FIRST_COMPONENT_EXPORTS)[number]; +const SERVER_FIRST_COMPONENT_EXPORTS_SET = new Set( + SERVER_FIRST_COMPONENT_EXPORTS, +); +function isServerFirstComponentExport( + name: string, +): name is ServerFirstComponentExport { + return SERVER_FIRST_COMPONENT_EXPORTS_SET.has( + name as ServerFirstComponentExport, + ); +} + +const CLIENT_COMPONENT_EXPORTS = [ + ...COMMON_COMPONENT_EXPORTS, + "default", +] as const; + export const CLIENT_NON_COMPONENT_EXPORTS = [ "clientAction", "clientLoader", @@ -37,7 +60,7 @@ function isClientNonComponentExport( const CLIENT_ROUTE_EXPORTS = [ ...CLIENT_NON_COMPONENT_EXPORTS, - ...COMPONENT_EXPORTS, + ...CLIENT_COMPONENT_EXPORTS, ] as const; type ClientRouteExport = (typeof CLIENT_ROUTE_EXPORTS)[number]; const CLIENT_ROUTE_EXPORTS_SET = new Set(CLIENT_ROUTE_EXPORTS); @@ -90,18 +113,24 @@ async function createVirtualRouteModuleCode({ let code = ""; if (isServerFirstRoute) { + if (staticExports.some(isServerFirstComponentExport)) { + code += `import React from "react";\n`; + } for (const staticExport of staticExports) { if (isClientNonComponentExport(staticExport)) { code += `export { ${staticExport} } from "${clientModuleId}";\n`; - } else if (staticExport === "ServerComponent") { - code += `import React from "react";\n`; - code += `import { ServerComponent } from "${serverModuleId}";\n`; - code += `export default function ServerComponentWithCss(props) {`; - code += ` return React.createElement(React.Fragment, null, [`; - code += ` import.meta.viteRsc.loadCss(),`; - code += ` React.createElement(ServerComponent, props),`; - code += ` ]);`; - code += `}`; + } else if ( + isServerFirstComponentExport(staticExport) && + // Layout wraps all other exports so doesn't need to have CSS injected + staticExport !== "Layout" + ) { + code += `import { ${staticExport} as ${staticExport}WithoutCss } from "${serverModuleId}";\n`; + code += `export ${staticExport === "ServerComponent" ? "default " : " "}function ${staticExport}(props) {\n`; + code += ` return React.createElement(React.Fragment, null,\n`; + code += ` import.meta.viteRsc.loadCss(),\n`; + code += ` React.createElement(${staticExport}WithoutCss, props),\n`; + code += ` );\n`; + code += `}\n`; } else { code += `export { ${staticExport} } from "${serverModuleId}";\n`; } @@ -169,7 +198,7 @@ function createVirtualClientRouteModuleCode({ const { staticExports, isServerFirstRoute, hasClientExports } = parseRouteExports(routeSource); const exportsToRemove = isServerFirstRoute - ? [...SERVER_ONLY_ROUTE_EXPORTS, ...COMPONENT_EXPORTS] + ? [...SERVER_ONLY_ROUTE_EXPORTS, ...CLIENT_COMPONENT_EXPORTS] : SERVER_ONLY_ROUTE_EXPORTS; const clientRouteModuleAst = babel.parse(routeSource, { From 7382c84dc899161a4b941a3b9d05ce89f31b4873 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 20 Aug 2025 17:37:50 +1000 Subject: [PATCH 6/6] Update comment --- packages/react-router-dev/vite/rsc/virtual-route-modules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts index 395debd7e8..25bffa8673 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -121,7 +121,7 @@ async function createVirtualRouteModuleCode({ code += `export { ${staticExport} } from "${clientModuleId}";\n`; } else if ( isServerFirstComponentExport(staticExport) && - // Layout wraps all other exports so doesn't need to have CSS injected + // Layout wraps all other component exports so doesn't need CSS injected staticExport !== "Layout" ) { code += `import { ${staticExport} as ${staticExport}WithoutCss } from "${serverModuleId}";\n`;