Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/rsc-user-ssr-entry.md
Original file line number Diff line number Diff line change
@@ -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`.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep build callable in production

When this getLoadContext is added to the complete custom-server example above and the documented production start command is used, build is the resolved ServerBuild object assigned on lines 426–428, not a function, so the first request throws TypeError: build is not a function. Keep the production value behind the same callable interface used in development, as the new integration fixture does, or branch here before accessing entry.module.

Useful? React with 👍 / 👎.

return new RouterContextProvider([[valueContext, 'value']]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Import RouterContextProvider in the server example

When users add this fragment to the custom-server example immediately above, handling a request fails with ReferenceError: RouterContextProvider is not defined because that example's imports include only createRequestHandler from the React Router packages. Add the corresponding react-router import to the documented server setup.

Useful? React with 👍 / 👎.

},
```

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.
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ export const pluginReactRouter = (
buildDirectory,
finalEntryRscClientPath,
finalEntryRscPath,
finalEntryRscSsrPath,
outputClientPath,
pluginName: PLUGIN_NAME,
serverBuildFile,
Expand Down
6 changes: 5 additions & 1 deletion src/mode-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ type CreateRscModePlanOptions = ModePlanContext & {
buildDirectory: string;
finalEntryRscClientPath: string;
finalEntryRscPath: string;
finalEntryRscSsrPath: string;
outputClientPath: string;
pluginName: string;
serverBuildFile: string | undefined;
Expand Down Expand Up @@ -133,6 +134,7 @@ const createRscModePlan = async ({
customServer,
finalEntryRscClientPath,
finalEntryRscPath,
finalEntryRscSsrPath,
isBuild,
outputClientPath,
pluginName,
Expand Down Expand Up @@ -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
? {
Expand Down
11 changes: 11 additions & 0 deletions src/rsc-runtime.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>;
}

declare module 'virtual/react-router/unstable_rsc/bootstrap-scripts' {
const bootstrapScripts: string[];
export default bootstrapScripts;
Expand Down
12 changes: 11 additions & 1 deletion src/rsc-virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,18 @@ type RscVirtualModulesOptions = {
};

export const createReactRouterRscResolveAliases = (
rootPath: string
rootPath: string,
options: { entrySsrPath?: string } = {}
): Record<string, string> => ({
// 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}`;
Expand Down
2 changes: 1 addition & 1 deletion src/templates/entry.rsc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
10 changes: 6 additions & 4 deletions tests/react-router-framework/integration/basename-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
98 changes: 71 additions & 27 deletions tests/react-router-framework/integration/loader-context-test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string>();
`,
"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(
<ServerRouter context={routerContext} url={request.url} />,
);
responseHeaders.set("Content-Type", "text/html");
return new Response("<!DOCTYPE html>" + 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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading