-
-
-
-
CSS test
+ 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 }];
+ }
+
+ function TestRoute() {
+ return (
+ <>
+
+
-
-
-
- >
- );
- }
- `,
-};
-
-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,
- }),
- ],
- });
-`;
+ >
+ );
+ }
+
+ export ${exportName === "default" ? "default" : `const ${exportName} =`} TestRoute;
+ `,
+ };
+ }),
+ ),
+});
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 +207,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 VITE_CONFIG({ port }),
- ...files,
+ "vite.config.ts": await viteConfig.basic({
+ port,
+ templateName,
+ vanillaExtract: true,
+ }),
+ ...files({ templateName }),
},
templateName,
);
@@ -204,20 +225,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 +256,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 +273,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 +300,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 +320,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 +342,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 +370,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 +415,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 +446,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 +474,34 @@ 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));
+ 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",
+ });
- 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),
- ),
- );
+ 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,60 +509,77 @@ async function hmrWorkflow({
cwd,
port,
base,
+ templateName,
}: {
page: Page;
cwd: string;
port: number;
base?: string;
+ templateName: TemplateName;
}) {
- let pageErrors: Error[] = [];
- page.on("pageerror", (error) => pageErrors.push(error));
+ if (templateName.includes("rsc")) {
+ // TODO: Fix CSS HMR support in RSC Framework mode
+ return;
+ }
- await page.goto(`http://localhost:${port}${base ?? "/"}`, {
- waitUntil: "networkidle",
- });
+ for (const routeBase of getRouteBasePaths(templateName)) {
+ let pageErrors: Error[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error));
- 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 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",
+ );
+
+ 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([]);
+ }
}
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 e0093b7cc3..8c54a90676 100644
--- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts
+++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts
@@ -3,12 +3,14 @@ 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;
type ServerOnlyRouteExport = (typeof SERVER_ONLY_ROUTE_EXPORTS)[number];
const SERVER_ONLY_ROUTE_EXPORTS_SET = new Set(SERVER_ONLY_ROUTE_EXPORTS);
@@ -16,13 +18,34 @@ function isServerOnlyRouteExport(name: string): name is ServerOnlyRouteExport {
return SERVER_ONLY_ROUTE_EXPORTS_SET.has(name as ServerOnlyRouteExport);
}
-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",
@@ -42,7 +65,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);
@@ -120,11 +143,27 @@ 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 (
+ isReactServer &&
+ isServerFirstComponentExport(staticExport) &&
+ // Layout wraps all other component exports so doesn't need 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 if (isReactServer && isRouteExport(staticExport)) {
- code += `export { ${staticExport}${staticExport === "ServerComponent" ? " as default" : ""} } from "${serverModuleId}";\n`;
+ code += `export { ${staticExport} } from "${serverModuleId}";\n`;
} else if (isCustomRouteExport(staticExport)) {
code += `export { ${staticExport} } from "${isReactServer ? serverModuleId : clientModuleId}";\n`;
}
@@ -206,7 +245,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, {
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))