From bfe74425cd2daeb3c8681087209b1dd124936b77 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 2 Oct 2025 17:18:31 +1000 Subject: [PATCH 1/2] Keep non-ServerComponent exports as client components --- .changeset/gorgeous-panthers-hug.md | 6 + docs/how-to/react-server-components.md | 6 +- integration/typegen-test.ts | 98 +---------- .../vite/rsc/virtual-route-modules.ts | 160 ++++++------------ .../lib/types/route-module-annotations.ts | 58 +++---- 5 files changed, 80 insertions(+), 248 deletions(-) create mode 100644 .changeset/gorgeous-panthers-hug.md diff --git a/.changeset/gorgeous-panthers-hug.md b/.changeset/gorgeous-panthers-hug.md new file mode 100644 index 0000000000..b18bed8698 --- /dev/null +++ b/.changeset/gorgeous-panthers-hug.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +In (unstable) RSC Framework Mode, always keep the `ErrorBoundary`, `HydrateFallback` and `Layout` Route Module exports as client components, even when a `ServerComponent` export is present diff --git a/docs/how-to/react-server-components.md b/docs/how-to/react-server-components.md index c38fab5c87..1290c3471d 100644 --- a/docs/how-to/react-server-components.md +++ b/docs/how-to/react-server-components.md @@ -32,8 +32,8 @@ The quickest way to get started is with one of our templates. These templates come with React Router RSC APIs already configured, offering you out of the box features such as: -- Server Component Routes - Server Side Rendering (SSR) +- Server Components - Client Components (via [`"use client"`][use-client-docs] directive) - Server Functions (via [`"use server"`][use-server-docs] directive) @@ -177,9 +177,9 @@ export default function Route({ } ``` -### Server Component Routes +### Route Server Components -If a route exports a `ServerComponent` instead of the typical `default` component export, this component along with other route components (`ErrorBoundary`, `HydrateFallback`, `Layout`) will be server components rather than the usual client components. +If a route exports a `ServerComponent` instead of the typical `default` component export, this will be a server component rather than the usual client component. ```tsx import type { Route } from "./+types/route"; diff --git a/integration/typegen-test.ts b/integration/typegen-test.ts index 9ffcc5e183..eb28714228 100644 --- a/integration/typegen-test.ts +++ b/integration/typegen-test.ts @@ -658,7 +658,7 @@ test.describe("typegen", () => { }); }); - test.describe("server-first route component detection", () => { + test.describe("route server component detection", () => { test.describe("ServerComponent export", () => { test("when RSC Framework Mode plugin is present", async ({ edit, $ }) => { await edit({ @@ -706,38 +706,6 @@ test.describe("typegen", () => { ) } - - export function ErrorBoundary({ - loaderData, - actionData - }: Route.ErrorBoundaryProps) { - type TestLoaderData = Expect> - type TestActionData = Expect> - - return ( - <> -

ErrorBoundary

-

Loader data: {loaderData?.server}

-

Action data: {actionData?.server}

- - ) - } - - export function HydrateFallback({ - loaderData, - actionData - }: Route.HydrateFallbackProps) { - type TestLoaderData = Expect> - type TestActionData = Expect> - - return ( - <> -

HydrateFallback

-

Loader data: {loaderData?.server}

-

Action data: {actionData?.server}

- - ) - } `, }); await $("pnpm typecheck"); @@ -795,38 +763,6 @@ test.describe("typegen", () => { ) } - - export function ErrorBoundary({ - loaderData, - actionData - }: Route.ErrorBoundaryProps) { - type TestLoaderData = Expect> - type TestActionData = Expect> - - return ( - <> -

ErrorBoundary

- {loaderData &&

Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}

} - {actionData &&

Action data: {"server" in actionData ? actionData.server : actionData.client}

} - - ) - } - - export function HydrateFallback({ - loaderData, - actionData - }: Route.HydrateFallbackProps) { - type TestLoaderData = Expect> - type TestActionData = Expect> - - return ( - <> -

HydrateFallback

- {loaderData &&

Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}

} - {actionData &&

Action data: {"server" in actionData ? actionData.server : actionData.client}

} - - ) - } `, }); await $("pnpm typecheck"); @@ -878,38 +814,6 @@ test.describe("typegen", () => { ) } - - export function ErrorBoundary({ - loaderData, - actionData - }: Route.ErrorBoundaryProps) { - type TestLoaderData = Expect> - type TestActionData = Expect> - - return ( - <> -

ErrorBoundary

- {loaderData &&

Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}

} - {actionData &&

Action data: {"server" in actionData ? actionData.server : actionData.client}

} - - ) - } - - export function HydrateFallback({ - loaderData, - actionData - }: Route.HydrateFallbackProps) { - type TestLoaderData = Expect> - type TestActionData = Expect> - - return ( - <> -

HydrateFallback

- {loaderData &&

Loader data: {"server" in loaderData ? loaderData.server : loaderData.client}

} - {actionData &&

Action data: {"server" in actionData ? actionData.server : actionData.client}

} - - ) - } `, }; 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 5c5cdea04e..c86bae1d39 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -3,49 +3,27 @@ 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_COMPONENT_EXPORTS = ["ServerComponent"] as const; -const SERVER_ONLY_ROUTE_EXPORTS = [ - ...SERVER_ONLY_COMPONENT_EXPORTS, +type ServerComponentExport = (typeof SERVER_COMPONENT_EXPORTS)[number]; +const SERVER_COMPONENT_EXPORTS_SET = new Set(SERVER_COMPONENT_EXPORTS); +function isServerComponentExport(name: string): name is ServerComponentExport { + return SERVER_COMPONENT_EXPORTS_SET.has(name as ServerComponentExport); +} + +const SERVER_ROUTE_EXPORTS = [ + ...SERVER_COMPONENT_EXPORTS, "loader", "action", "middleware", "headers", ] as const; -type ServerOnlyRouteExport = (typeof SERVER_ONLY_ROUTE_EXPORTS)[number]; -const SERVER_ONLY_ROUTE_EXPORTS_SET = new Set(SERVER_ONLY_ROUTE_EXPORTS); -function isServerOnlyRouteExport(name: string): name is ServerOnlyRouteExport { - return SERVER_ONLY_ROUTE_EXPORTS_SET.has(name as ServerOnlyRouteExport); -} - -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, - ); +type ServerRouteExport = (typeof SERVER_ROUTE_EXPORTS)[number]; +const SERVER_ROUTE_EXPORTS_SET = new Set(SERVER_ROUTE_EXPORTS); +function isServerRouteExport(name: string): name is ServerRouteExport { + return SERVER_ROUTE_EXPORTS_SET.has(name as ServerRouteExport); } -const CLIENT_COMPONENT_EXPORTS = [ - ...COMMON_COMPONENT_EXPORTS, - "default", -] as const; - export const CLIENT_NON_COMPONENT_EXPORTS = [ "clientAction", "clientLoader", @@ -55,17 +33,13 @@ export const CLIENT_NON_COMPONENT_EXPORTS = [ "links", "shouldRevalidate", ] as const; -type ClientNonComponentExport = (typeof CLIENT_NON_COMPONENT_EXPORTS)[number]; -const CLIENT_NON_COMPONENT_EXPORTS_SET = new Set(CLIENT_NON_COMPONENT_EXPORTS); -function isClientNonComponentExport( - name: string, -): name is ClientNonComponentExport { - return CLIENT_NON_COMPONENT_EXPORTS_SET.has(name as ClientNonComponentExport); -} const CLIENT_ROUTE_EXPORTS = [ ...CLIENT_NON_COMPONENT_EXPORTS, - ...CLIENT_COMPONENT_EXPORTS, + "default", + "ErrorBoundary", + "HydrateFallback", + "Layout", ] as const; type ClientRouteExport = (typeof CLIENT_ROUTE_EXPORTS)[number]; const CLIENT_ROUTE_EXPORTS_SET = new Set(CLIENT_ROUTE_EXPORTS); @@ -74,7 +48,7 @@ function isClientRouteExport(name: string): name is ClientRouteExport { } const ROUTE_EXPORTS = [ - ...SERVER_ONLY_ROUTE_EXPORTS, + ...SERVER_ROUTE_EXPORTS, ...CLIENT_ROUTE_EXPORTS, ] as const; type RouteExport = (typeof ROUTE_EXPORTS)[number]; @@ -149,51 +123,30 @@ async function createVirtualRouteModuleCode({ viteEnvironment: Vite.Environment; }) { const isReactServer = hasReactServerCondition(viteEnvironment); - const { staticExports, isServerFirstRoute, hasClientExports } = - parseRouteExports(routeSource); + const { staticExports, hasClientExports } = parseRouteExports(routeSource); const clientModuleId = getVirtualClientModuleId(id); const serverModuleId = getVirtualServerModuleId(id); 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} } from "${serverModuleId}";\n`; - } else if (isCustomRouteExport(staticExport)) { - code += `export { ${staticExport} } from "${isReactServer ? serverModuleId : clientModuleId}";\n`; - } - } - if (viteCommand === "serve" && !hasClientExports) { - code += `export { __ensureClientRouteModuleForHMR } from "${clientModuleId}";\n`; - } - } else { - for (const staticExport of staticExports) { - if (isClientRouteExport(staticExport)) { - code += `export { ${staticExport} } from "${clientModuleId}";\n`; - } else if (isReactServer && isServerOnlyRouteExport(staticExport)) { - code += `export { ${staticExport} } from "${serverModuleId}";\n`; - } else if (isCustomRouteExport(staticExport)) { - code += `export { ${staticExport} } from "${isReactServer ? serverModuleId : clientModuleId}";\n`; - } + if (isReactServer && staticExports.some(isServerComponentExport)) { + code += `import React from "react";\n`; + } + for (const staticExport of staticExports) { + if (isReactServer && isServerComponentExport(staticExport)) { + 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 && isServerRouteExport(staticExport)) { + code += `export { ${staticExport} } from "${serverModuleId}";\n`; + } else if (isClientRouteExport(staticExport)) { + code += `export { ${staticExport} } from "${clientModuleId}";\n`; + } else if (isCustomRouteExport(staticExport)) { + code += `export { ${staticExport} } from "${isReactServer ? serverModuleId : clientModuleId}";\n`; } } @@ -204,6 +157,10 @@ async function createVirtualRouteModuleCode({ code += `export { ErrorBoundary } from "${clientModuleId}";\n`; } + if (viteCommand === "serve" && !hasClientExports) { + code += `export { __ensureClientRouteModuleForHMR } from "${clientModuleId}";\n`; + } + return code; } @@ -226,24 +183,19 @@ function createVirtualServerRouteModuleCode({ ); } - const { staticExports, isServerFirstRoute } = parseRouteExports(routeSource); + const { staticExports } = parseRouteExports(routeSource); const clientModuleId = getVirtualClientModuleId(id); const serverRouteModuleAst = babel.parse(routeSource, { sourceType: "module", }); - removeExports( - serverRouteModuleAst, - isServerFirstRoute ? CLIENT_NON_COMPONENT_EXPORTS : CLIENT_ROUTE_EXPORTS, - ); + removeExports(serverRouteModuleAst, CLIENT_ROUTE_EXPORTS); const generatorResult = babel.generate(serverRouteModuleAst); - if (!isServerFirstRoute) { - for (const staticExport of staticExports) { - if (isClientRouteExport(staticExport)) { - generatorResult.code += "\n"; - generatorResult.code += `export { ${staticExport} } from "${clientModuleId}";\n`; - } + for (const staticExport of staticExports) { + if (isClientRouteExport(staticExport)) { + generatorResult.code += "\n"; + generatorResult.code += `export { ${staticExport} } from "${clientModuleId}";\n`; } } @@ -261,16 +213,12 @@ function createVirtualClientRouteModuleCode({ rootRouteFile: string; viteCommand: ViteCommand; }) { - const { staticExports, isServerFirstRoute, hasClientExports } = - parseRouteExports(routeSource); - const exportsToRemove = isServerFirstRoute - ? [...SERVER_ONLY_ROUTE_EXPORTS, ...CLIENT_COMPONENT_EXPORTS] - : SERVER_ONLY_ROUTE_EXPORTS; + const { staticExports, hasClientExports } = parseRouteExports(routeSource); const clientRouteModuleAst = babel.parse(routeSource, { sourceType: "module", }); - removeExports(clientRouteModuleAst, exportsToRemove); + removeExports(clientRouteModuleAst, SERVER_ROUTE_EXPORTS); const generatorResult = babel.generate(clientRouteModuleAst); generatorResult.code = '"use client";' + generatorResult.code; @@ -287,7 +235,7 @@ function createVirtualClientRouteModuleCode({ generatorResult.code += `}\n`; } - if (viteCommand === "serve" && isServerFirstRoute && !hasClientExports) { + if (viteCommand === "serve" && !hasClientExports) { generatorResult.code += `\nexport const __ensureClientRouteModuleForHMR = true;`; } @@ -297,15 +245,9 @@ function createVirtualClientRouteModuleCode({ export function parseRouteExports(code: string) { const [, exportSpecifiers] = esModuleLexer(code); const staticExports = exportSpecifiers.map(({ n: name }) => name); - const isServerFirstRoute = staticExports.some( - (staticExport) => staticExport === "ServerComponent", - ); return { staticExports, - isServerFirstRoute, - hasClientExports: staticExports.some( - isServerFirstRoute ? isClientNonComponentExport : isClientRouteExport, - ), + hasClientExports: staticExports.some(isClientRouteExport), }; } diff --git a/packages/react-router/lib/types/route-module-annotations.ts b/packages/react-router/lib/types/route-module-annotations.ts index 30ba7c983e..d98723eb2b 100644 --- a/packages/react-router/lib/types/route-module-annotations.ts +++ b/packages/react-router/lib/types/route-module-annotations.ts @@ -120,7 +120,7 @@ type CreateClientActionArgs = ClientDataFunctionArgs< serverAction: () => Promise>; }; -type IsServerFirstRoute< +type HasServerComponent< T extends RouteInfo, RSCEnabled extends boolean, > = RSCEnabled extends true @@ -129,24 +129,14 @@ type IsServerFirstRoute< : false : false; -type CreateHydrateFallbackProps< - T extends RouteInfo, - RSCEnabled extends boolean, -> = { +type CreateHydrateFallbackProps = { params: T["params"]; -} & (IsServerFirstRoute extends true - ? { - /** The data returned from the `loader` */ - loaderData?: ServerDataFrom; - /** The data returned from the `action` following an action submission. */ - actionData?: ServerDataFrom; - } - : { - /** The data returned from the `loader` or `clientLoader` */ - loaderData?: T["loaderData"]; - /** The data returned from the `action` or `clientAction` following an action submission. */ - actionData?: T["actionData"]; - }); +} & { + /** The data returned from the `loader` or `clientLoader` */ + loaderData?: T["loaderData"]; + /** The data returned from the `action` or `clientAction` following an action submission. */ + actionData?: T["actionData"]; +}; type Match = Pretty<{ id: T["id"]; @@ -182,7 +172,7 @@ type CreateComponentProps = { params: T["params"]; /** An array of the current {@link https://api.reactrouter.com/v7/interfaces/react_router.UIMatch.html route matches}, including parent route matches. */ matches: Matches; -} & (IsServerFirstRoute extends true +} & (HasServerComponent extends true ? { /** The data returned from the `loader` */ loaderData: ServerDataFrom; @@ -196,10 +186,7 @@ type CreateComponentProps = { actionData?: T["actionData"]; }); -type CreateErrorBoundaryProps< - T extends RouteInfo, - RSCEnabled extends boolean, -> = { +type CreateErrorBoundaryProps = { /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example @@ -216,19 +203,12 @@ type CreateErrorBoundaryProps< **/ params: T["params"]; error: unknown; -} & (IsServerFirstRoute extends true - ? { - /** The data returned from the `loader` */ - loaderData?: ServerDataFrom; - /** The data returned from the `action` following an action submission. */ - actionData?: ServerDataFrom; - } - : { - /** The data returned from the `loader` or `clientLoader` */ - loaderData?: T["loaderData"]; - /** The data returned from the `action` or `clientAction` following an action submission. */ - actionData?: T["actionData"]; - }); +} & { + /** The data returned from the `loader` or `clientLoader` */ + loaderData?: T["loaderData"]; + /** The data returned from the `action` or `clientAction` following an action submission. */ + actionData?: T["actionData"]; +}; export type GetAnnotations< Info extends RouteInfo, @@ -266,13 +246,13 @@ export type GetAnnotations< ClientActionArgs: CreateClientActionArgs; // HydrateFallback - HydrateFallbackProps: CreateHydrateFallbackProps; + HydrateFallbackProps: CreateHydrateFallbackProps; - // default (Component) + // default (Component) / ServerComponent ComponentProps: CreateComponentProps; // ErrorBoundary - ErrorBoundaryProps: CreateErrorBoundaryProps; + ErrorBoundaryProps: CreateErrorBoundaryProps; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars From 1942dc7778cb36e21541cb13686086ef83e0f4a5 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 2 Oct 2025 17:31:09 +1000 Subject: [PATCH 2/2] Remove redundant `&` in types --- packages/react-router/lib/types/route-module-annotations.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react-router/lib/types/route-module-annotations.ts b/packages/react-router/lib/types/route-module-annotations.ts index d98723eb2b..ed9e9a68ae 100644 --- a/packages/react-router/lib/types/route-module-annotations.ts +++ b/packages/react-router/lib/types/route-module-annotations.ts @@ -131,7 +131,6 @@ type HasServerComponent< type CreateHydrateFallbackProps = { params: T["params"]; -} & { /** The data returned from the `loader` or `clientLoader` */ loaderData?: T["loaderData"]; /** The data returned from the `action` or `clientAction` following an action submission. */ @@ -203,7 +202,6 @@ type CreateErrorBoundaryProps = { **/ params: T["params"]; error: unknown; -} & { /** The data returned from the `loader` or `clientLoader` */ loaderData?: T["loaderData"]; /** The data returned from the `action` or `clientAction` following an action submission. */