From d3b5178e08762578e78058aab6f4bb1332466f16 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:42:33 +0000 Subject: [PATCH] test: reach parity with upstream React Router corpus coverage Port the remaining upstream suites and close the plugin gap they exposed: - rsc-nonce-test: CSP nonces in RSC framework and data modes. The SSR entries pass the nonce to the Flight client too, because React Router does not forward it and the rspack Flight client preloads client reference chunks with script tags. - plugin-order-validation-test: the MDX-before-React-Router rule this plugin enforces, in classic and RSC framework modes. The Vite plugin-rsc ordering cases have no Rsbuild equivalent. - loader-context-test: un-skipped. A custom dev server builds its RouterContextProvider from the context instance the server build exposes through entry.module, using customServer mode and loadReactRouterServerBuild; documented in the README. - basename-test: the base/basename startup validation case is marked not applicable with the reason; Rsbuild serves a basename outside base, and the "works when basename does not start with base" cases cover it. Plugin fix: a user-provided app/entry.ssr.tsx in RSC framework mode failed the build because the RSC entry template imported its own SSR template directly, leaving the template compiled as React Server code. The template now imports the resolved SSR entry through the virtual/react-router/unstable_rsc/entry-ssr alias. Not portable: vite-plugin-cloudflare-test (Vite Cloudflare dev plugin); the cloudflare example keeps its own end-to-end coverage. Co-Authored-By: Claude Fable 5.1 --- .changeset/rsc-user-ssr-entry.md | 9 + README.md | 23 +++ src/index.ts | 1 + src/mode-plan.ts | 6 +- src/rsc-runtime.d.ts | 11 ++ src/rsc-virtual-modules.ts | 12 +- src/templates/entry.rsc.tsx | 2 +- .../integration/basename-test.ts | 10 +- .../integration/loader-context-test.ts | 98 +++++++--- .../plugin-order-validation-test.ts | 55 ++++++ .../integration/rsc-nonce-test.ts | 167 ++++++++++++++++++ tests/rsc-support.test.ts | 16 ++ 12 files changed, 376 insertions(+), 34 deletions(-) create mode 100644 .changeset/rsc-user-ssr-entry.md create mode 100644 tests/react-router-framework/integration/plugin-order-validation-test.ts create mode 100644 tests/react-router-framework/integration/rsc-nonce-test.ts diff --git a/.changeset/rsc-user-ssr-entry.md b/.changeset/rsc-user-ssr-entry.md new file mode 100644 index 00000000..efff355b --- /dev/null +++ b/.changeset/rsc-user-ssr-entry.md @@ -0,0 +1,9 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Honor a user-provided `app/entry.ssr.tsx` in RSC framework mode. The RSC entry +template imported its own SSR template directly, so an override was placed in +the SSR layer while the template kept being compiled as React Server code and +failed the build on `react-dom/server`. The template now imports the resolved +SSR entry through `virtual/react-router/unstable_rsc/entry-ssr`. diff --git a/README.md b/README.md index e43c249c..286d48cc 100644 --- a/README.md +++ b/README.md @@ -465,6 +465,29 @@ and every configured bundle are evaluated and published as one generation; one failing bundle keeps the whole previous generation active. +### Sharing `createContext()` instances with a custom server + +React Router middleware contexts are matched by identity, so a custom server's +`getLoadContext` must use the same `createContext()` instance the routes import. +With a bundled server build that instance lives inside the build. Re-export it +from `app/entry.server.tsx` and read it from `build.entry.module`: + +```ts +// app/entry.server.tsx +export { valueContext } from './context'; +``` + +```js +// server.js +getLoadContext: async () => { + const { valueContext } = (await build()).entry.module; + return new RouterContextProvider([[valueContext, 'value']]); +}, +``` + +This works in development through `loadReactRouterServerBuild` and in +production through `resolveReactRouterServerBuild`. + `resolveReactRouterServerBuild` accepts an imported production server module, normalizes ESM and CommonJS namespace shapes, resolves supported asynchronous build exports, and validates the result before it reaches React Router. diff --git a/src/index.ts b/src/index.ts index d3719e73..4e45c6fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -585,6 +585,7 @@ export const pluginReactRouter = ( buildDirectory, finalEntryRscClientPath, finalEntryRscPath, + finalEntryRscSsrPath, outputClientPath, pluginName: PLUGIN_NAME, serverBuildFile, diff --git a/src/mode-plan.ts b/src/mode-plan.ts index c50dd5b3..348314ca 100644 --- a/src/mode-plan.ts +++ b/src/mode-plan.ts @@ -104,6 +104,7 @@ type CreateRscModePlanOptions = ModePlanContext & { buildDirectory: string; finalEntryRscClientPath: string; finalEntryRscPath: string; + finalEntryRscSsrPath: string; outputClientPath: string; pluginName: string; serverBuildFile: string | undefined; @@ -133,6 +134,7 @@ const createRscModePlan = async ({ customServer, finalEntryRscClientPath, finalEntryRscPath, + finalEntryRscSsrPath, isBuild, outputClientPath, pluginName, @@ -197,7 +199,9 @@ const createRscModePlan = async ({ }), createResolveConfig: (rootPath: string) => ({ modules: [resolve(rootPath, 'node_modules'), 'node_modules'], - alias: createReactRouterRscResolveAliases(rootPath), + alias: createReactRouterRscResolveAliases(rootPath, { + entrySsrPath: finalEntryRscSsrPath, + }), }), server: !customServer ? { diff --git a/src/rsc-runtime.d.ts b/src/rsc-runtime.d.ts index 5babc143..99343e34 100644 --- a/src/rsc-runtime.d.ts +++ b/src/rsc-runtime.d.ts @@ -99,6 +99,17 @@ declare module 'virtual/react-router/unstable_rsc/react-router-serve-config' { declare module 'virtual/react-router/unstable_rsc/inject-hmr-runtime' {} +declare module 'virtual/react-router/unstable_rsc/entry-ssr' { + export function generateHTML( + request: Request, + serverResponse: Response, + options?: { + bootstrapScripts?: string[]; + bootstrapModules?: string[]; + } + ): Promise; +} + declare module 'virtual/react-router/unstable_rsc/bootstrap-scripts' { const bootstrapScripts: string[]; export default bootstrapScripts; diff --git a/src/rsc-virtual-modules.ts b/src/rsc-virtual-modules.ts index 45eb4a59..7ea6fca3 100644 --- a/src/rsc-virtual-modules.ts +++ b/src/rsc-virtual-modules.ts @@ -39,8 +39,18 @@ type RscVirtualModulesOptions = { }; export const createReactRouterRscResolveAliases = ( - rootPath: string + rootPath: string, + options: { entrySsrPath?: string } = {} ): Record => ({ + // The RSC entry template imports the SSR entry through this alias so a + // user-provided `app/entry.ssr.tsx` replaces the template inside the SSR + // layer instead of leaving the template to be compiled as server code. + ...(options.entrySsrPath + ? { + 'virtual:react-router/unstable_rsc/entry-ssr': options.entrySsrPath, + 'virtual/react-router/unstable_rsc/entry-ssr': options.entrySsrPath, + } + : {}), ...Object.fromEntries( RSC_VIRTUAL_ALIAS_IDS.flatMap(id => { const moduleId = `virtual/react-router/unstable_rsc/${id}`; diff --git a/src/templates/entry.rsc.tsx b/src/templates/entry.rsc.tsx index b8aca646..bc10202a 100644 --- a/src/templates/entry.rsc.tsx +++ b/src/templates/entry.rsc.tsx @@ -19,7 +19,7 @@ import clientVersion from 'virtual/react-router/unstable_rsc/client-version'; import unstable_reactRouterServeConfig from 'virtual/react-router/unstable_rsc/react-router-serve-config'; import bootstrapScripts from 'virtual/react-router/unstable_rsc/bootstrap-scripts'; import getServerManifest from 'virtual/react-router/unstable_rsc/server-manifest'; -import { generateHTML } from './entry.rsc.ssr.js'; +import { generateHTML } from 'virtual/react-router/unstable_rsc/entry-ssr'; export { unstable_reactRouterServeConfig }; diff --git a/tests/react-router-framework/integration/basename-test.ts b/tests/react-router-framework/integration/basename-test.ts index 702701ed..978a90a8 100644 --- a/tests/react-router-framework/integration/basename-test.ts +++ b/tests/react-router-framework/integration/basename-test.ts @@ -248,12 +248,14 @@ test.describe("base + React Router basename", () => { test("errors if basename does not start with base", async ({ page, }) => { - // Vite-only: the base/basename startup validation lives in - // @react-router/dev/vite. Without it `rsbuild dev` never exits, - // which hangs the sync spawn below. + // Not applicable to Rsbuild. Vite's dev server only serves under + // `base`, so upstream refuses a `basename` outside it at startup. + // The Rsbuild dev middleware serves the app at any `basename`; the + // "works when basename does not start with base" cases below cover + // that behavior, and `rsbuild dev` would never exit here. test.skip( true, - "rsbuild-plugin-react-router has no base/basename startup validation", + "Rsbuild serves a basename outside base; see the 'works when basename does not start with base' cases", ); await setup({ base: "/mybase/", diff --git a/tests/react-router-framework/integration/loader-context-test.ts b/tests/react-router-framework/integration/loader-context-test.ts index 25da7d36..c09a4db3 100644 --- a/tests/react-router-framework/integration/loader-context-test.ts +++ b/tests/react-router-framework/integration/loader-context-test.ts @@ -1,12 +1,16 @@ import { test, expect } from "@playwright/test"; +import dedent from "dedent"; import getPort from "get-port"; -import { createProject, customDev, rsbuildConfig } from "./helpers/rsbuild.js"; +import { createProject, customDev } from "./helpers/rsbuild.js"; -test.skip( - true, - "Custom load context with a custom server needs adapter support for exposing route context modules", -); +// Adapted from upstream `vite-loader-context-test.ts`. Vite lets a custom +// server pull the context module out of its module graph with +// `ssrLoadModule("/app/context.ts")`. With a bundled server build the same +// instance is reached through the build itself: `app/entry.server.tsx` +// re-exports the context, and the custom server reads it from +// `build.entry.module`. `loadReactRouterServerBuild` hands out the last-good +// development build, so the loader and the load context share one module. let port: number; let cwd: string; @@ -15,51 +19,91 @@ let stop: (() => unknown) | undefined; test.beforeAll(async () => { port = await getPort(); cwd = await createProject({ - "rsbuild.config.ts": await rsbuildConfig.basic({ port }), + "rsbuild.config.ts": dedent` + import { defineConfig } from "@rsbuild/core"; + import { pluginReact } from "@rsbuild/plugin-react"; + import { pluginReactRouter } from "rsbuild-plugin-react-router"; + + export default defineConfig({ + server: { port: ${port}, strictPort: true }, + plugins: [pluginReact(), pluginReactRouter({ customServer: true })], + }); + `, "app/context.ts": String.raw` import { createContext } from "react-router"; export const valueContext = createContext(); `, + "app/entry.server.tsx": String.raw` + import { renderToString } from "react-dom/server"; + import { ServerRouter, type EntryContext } from "react-router"; + + // Re-exported so a custom server can build its load context from the + // very same context instance the routes import. + export { valueContext } from "./context"; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + ) { + const html = renderToString( + , + ); + responseHeaders.set("Content-Type", "text/html"); + return new Response("" + html, { + status: responseStatusCode, + headers: responseHeaders, + }); + } + `, "server.mjs": String.raw` import { createRequestHandler } from "@react-router/express"; + import { createRsbuild, loadConfig } from "@rsbuild/core"; import { RouterContextProvider } from "react-router"; + import { + loadReactRouterServerBuild, + resolveReactRouterServerBuild, + } from "rsbuild-plugin-react-router"; import express from "express"; const app = express(); + const isDev = process.env.NODE_ENV !== "production"; + let devServer; + let build; - if (process.env.NODE_ENV !== "production") { - // Dev-mode custom load context is not yet supported by the Rsbuild - // adapter: injecting a caller-owned RouterContextProvider requires the - // route's context module (\`app/context.ts\`) to resolve to the SAME - // instance the server bundle uses. The plugin owns the node - // environment's entry list (\`modePlan.nodeEntries\`), so a fixture - // cannot expose the context module as a separate \`loadBundle\` entry, - // and the ServerBuild does not re-export it. Supporting this needs a - // plugin-side change (e.g. exposing route context modules to custom - // dev servers), which is out of scope for the test harness. - throw new Error( - "Custom dev servers with a custom load context need an Rsbuild dev-server adapter that can expose the route context module", + if (isDev) { + const { content } = await loadConfig(); + const rsbuild = await createRsbuild({ rsbuildConfig: content }); + devServer = await rsbuild.createDevServer(); + app.use(devServer.middlewares); + build = () => loadReactRouterServerBuild(devServer); + } else { + app.use(express.static("build/client", { index: false })); + const productionBuild = await resolveReactRouterServerBuild( + import("./build/server/static/js/app.js"), ); + build = () => Promise.resolve(productionBuild); } - app.use( - "/assets", - express.static("build/client/assets", { immutable: true, maxAge: "1y" }) - ); - app.use(express.static("build/client", { maxAge: "1h" })); app.all( "*", createRequestHandler({ - build: await import("./build/index.js"), + build, + mode: isDev ? "development" : "production", getLoadContext: async () => { - let { valueContext } = await import("./build/server/app/context.js"); + const { valueContext } = (await build()).entry.module; return new RouterContextProvider([[valueContext, "value"]]); }, - }) + }), ); const port = ${port}; - app.listen(port, () => console.log('http://localhost:' + port)); + const server = app.listen(port, () => { + console.log('http://localhost:' + port); + devServer?.afterListen(); + }); + devServer?.connectWebSocket({ server }); `, "app/routes/_index.tsx": String.raw` import { useLoaderData } from "react-router"; diff --git a/tests/react-router-framework/integration/plugin-order-validation-test.ts b/tests/react-router-framework/integration/plugin-order-validation-test.ts new file mode 100644 index 00000000..01e2c737 --- /dev/null +++ b/tests/react-router-framework/integration/plugin-order-validation-test.ts @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test"; +import dedent from "dedent"; + +import { createProject, build, reactRouterConfig } from "./helpers/rsbuild.js"; + +// Adapted from upstream `vite-plugin-order-validation-test.ts`. The Vite cases +// about `@vitejs/plugin-rsc` ordering have no Rsbuild equivalent; the MDX rule +// is the one this plugin enforces. +test.describe("Rsbuild plugin order validation", () => { + test("Framework Mode with MDX plugin after React Router plugin", async () => { + let cwd = await createProject({ + "rsbuild.config.ts": dedent` + import { defineConfig } from "@rsbuild/core"; + import { pluginMdx } from "@rsbuild/plugin-mdx"; + import { pluginReact } from "@rsbuild/plugin-react"; + import { pluginReactRouter } from "rsbuild-plugin-react-router"; + + export default defineConfig({ + plugins: [pluginReact(), pluginReactRouter(), pluginMdx()], + }); + `, + }); + + let buildResult = build({ cwd }); + expect(buildResult.stderr.toString()).toContain( + 'The "rsbuild:mdx" plugin should be placed before the React Router plugin', + ); + expect(buildResult.status).not.toBe(0); + }); + + test("RSC Framework Mode with MDX plugin after React Router plugin", async () => { + let cwd = await createProject( + { + "rsbuild.config.ts": dedent` + import { defineConfig } from "@rsbuild/core"; + import { pluginMdx } from "@rsbuild/plugin-mdx"; + import { pluginReact } from "@rsbuild/plugin-react"; + import { pluginReactRouterRSC } from "rsbuild-plugin-react-router"; + + export default defineConfig({ + plugins: [pluginReact(), pluginReactRouterRSC(), pluginMdx()], + }); + `, + "react-router.config.ts": reactRouterConfig(), + }, + "rsc-framework", + ); + + let buildResult = build({ cwd }); + expect(buildResult.stderr.toString()).toContain( + 'The "rsbuild:mdx" plugin should be placed before the React Router plugin', + ); + expect(buildResult.status).not.toBe(0); + }); +}); diff --git a/tests/react-router-framework/integration/rsc-nonce-test.ts b/tests/react-router-framework/integration/rsc-nonce-test.ts new file mode 100644 index 00000000..d0018a37 --- /dev/null +++ b/tests/react-router-framework/integration/rsc-nonce-test.ts @@ -0,0 +1,167 @@ +import { expect, type Page } from "@playwright/test"; +import getPort from "get-port"; + +import { js } from "./helpers/create-fixture.js"; +import { test } from "./helpers/rsbuild.js"; +import { implementations, setupRscTest } from "./rsc/utils.js"; + +// Adapted from upstream `rsc-nonce-test.ts`. The user-provided SSR entries use +// this plugin's React Server touchpoints (`react-server-dom-rspack`) and receive +// the client bootstrap scripts from the RSC server entry instead of Vite's +// `import.meta.viteRsc.loadBootstrapScriptContent`. + +async function expectNonceSupport(page: Page, nonce: string) { + const scripts = page.locator("script"); + const count = await scripts.count(); + expect(count).toBeGreaterThan(0); + for (let index = 0; index < count; index++) { + expect( + await scripts + .nth(index) + .evaluate((script: HTMLScriptElement) => script.nonce), + ).toBe(nonce); + } + + await page.getByRole("button", { name: "Count: 0" }).click(); + await expect(page.getByRole("button", { name: "Count: 1" })).toBeVisible(); +} + +const nonceSsrEntry = (exportName: "generateHTML" | "default") => js` + import * as React from "react"; + import { renderToReadableStream } from "react-dom/server"; + import { + unstable_routeRSCServerRequest as routeRSCServerRequest, + unstable_RSCStaticRouter as RSCStaticRouter, + } from "react-router"; + import { createFromReadableStream } from "react-server-dom-rspack/client.node"; + + export ${exportName === "default" ? "default " : ""}async function ${ + exportName === "default" ? "handler" : "generateHTML" + }( + request: Request, + serverResponse: Response, + options: { bootstrapScripts?: string[]; bootstrapModules?: string[] } = {}, + ) { + const nonce = crypto.randomUUID(); + const response = await routeRSCServerRequest({ + request, + serverResponse, + // React Router does not forward the nonce to the Flight client, and the + // rspack Flight client preloads client-reference chunks with