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