From 3035559e5ec0a41c10aa093d380e1ddf7f04c35a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 25 Sep 2025 14:35:27 -0400 Subject: [PATCH 1/3] wip: fix framework props for reexported components --- packages/react-router-dev/vite/with-props.ts | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/react-router-dev/vite/with-props.ts b/packages/react-router-dev/vite/with-props.ts index 46fbd2f400..8f12cc9082 100644 --- a/packages/react-router-dev/vite/with-props.ts +++ b/packages/react-router-dev/vite/with-props.ts @@ -22,6 +22,72 @@ export const decorateComponentExportsWithProps = ( return uid; } + /** + * Rewrite any re-exports for named components (`default Component`, `HydrateFallback`, `ErrorBoundary`) + * into `export const = ` form in preparation for adding props HOCs in the next traversal. + * + * Case 1: `export { name, ... }` or `export { value as name, ... }` + * -> Rename `name` to `uid` where `uid` is a new unique identifier + * -> Insert `export const name = uid` + * + * Case 2: `export { name1, value as name 2, ... } from "source"` + * -> Insert `import { name as uid }` where `uid` is a new unique identifier + * -> Insert `export const name = uid` + */ + traverse(ast, { + ExportNamedDeclaration(path) { + if (path.node.declaration) return; + const { source } = path.node; + + const exports: Array<{ + uid: Babel.Identifier; + local: Babel.Identifier; + exported: Babel.Identifier; + }> = []; + for (const specifier of path.get("specifiers")) { + if (specifier.isExportSpecifier()) { + const { local, exported } = specifier.node; + const { name } = local; + if (!t.isIdentifier(exported)) continue; + const uid = path.scope.generateUidIdentifier(`_${name}`); + if (exported.name === "default" || isNamedComponentExport(name)) { + exports.push({ uid, local, exported }); + specifier.remove(); + } + } + } + if (exports.length === 0) return; + + if (source != null) { + // `import { local as uid } from "source"` + path.insertAfter([ + t.importDeclaration( + exports.map(({ local, uid }) => t.importSpecifier(uid, local)), + source, + ), + ]); + } else { + for (const { local, uid } of exports) { + path.scope.getBinding(local.name)?.scope.rename(uid.name); + } + } + + // `export const exported = uid` + path.insertAfter( + exports.map(({ uid, exported }) => { + if (exported.name === "default") { + return t.exportDefaultDeclaration(uid); + } + return t.exportNamedDeclaration( + t.variableDeclaration("const", [ + t.variableDeclarator(exported, uid), + ]), + ); + }), + ); + }, + }); + traverse(ast, { ExportDeclaration(path) { if (path.isExportDefaultDeclaration()) { From 2f593ed8b30997fe16d545c712d88a3fe8057efb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 25 Sep 2025 18:07:29 -0400 Subject: [PATCH 2/3] tests --- integration/framework-props-test.ts | 117 +++++++++++++++++++ packages/react-router-dev/vite/with-props.ts | 13 ++- 2 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 integration/framework-props-test.ts diff --git a/integration/framework-props-test.ts b/integration/framework-props-test.ts new file mode 100644 index 0000000000..13497cc8b2 --- /dev/null +++ b/integration/framework-props-test.ts @@ -0,0 +1,117 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import dedent from "dedent"; + +import type { Files } from "./helpers/vite.js"; +import { test, viteConfig } from "./helpers/vite.js"; + +const tsx = dedent; + +const files: Files = async ({ port }) => ({ + "vite.config.ts": tsx` + import { reactRouter } from "@react-router/dev/vite"; + + export default { + ${await viteConfig.server({ port })} + plugins: [reactRouter()], + } + `, + "app/lib/components.tsx": tsx` + function Component({ loaderData }: any) { + return

{loaderData.title}

; + } + export const ComponentAlias = Component; + export default Component; + + export function HydrateFallback() { + return
Loading...
; + } + export const HydrateFallbackAlias = HydrateFallback; + + export function ErrorBoundary() { + return
Error
; + } + export const ErrorBoundaryAlias = ErrorBoundary; + `, + "app/routes.ts": tsx` + import { type RouteConfig, index, route } from "@react-router/dev/routes"; + + export default [ + route("named-reexport-with-source", "routes/named-reexport-with-source.tsx"), + route("alias-reexport-with-source", "routes/alias-reexport-with-source.tsx"), + route("named-reexport-without-source", "routes/named-reexport-without-source.tsx"), + route("alias-reexport-without-source", "routes/alias-reexport-without-source.tsx"), + ] satisfies RouteConfig; + `, + "app/routes/named-reexport-with-source.tsx": tsx` + export const loader = () => ({ title: "named-reexport-with-source" }) + + export { + default, + HydrateFallback, + ErrorBoundary, + } from "../lib/components" + `, + "app/routes/alias-reexport-with-source.tsx": tsx` + export const loader = () => ({ title: "alias-reexport-with-source" }) + + export { + ComponentAlias as default, + HydrateFallbackAlias as HydrateFallback, + ErrorBoundaryAlias as ErrorBoundary, + } from "../lib/components" + `, + "app/routes/named-reexport-without-source.tsx": tsx` + import { ComponentAlias, HydrateFallbackAlias, ErrorBoundaryAlias } from "../lib/components" + + export const loader = () => ({ title: "named-reexport-without-source" }) + + export default ComponentAlias + const HydrateFallback = HydrateFallbackAlias + const ErrorBoundary = ErrorBoundaryAlias + + export { + // note: it would be invalid syntax to use 'default' keyword here, + // so instead we 'export default' separately + HydrateFallback, + ErrorBoundary, + } + `, + "app/routes/alias-reexport-without-source.tsx": tsx` + import { ComponentAlias, HydrateFallbackAlias, ErrorBoundaryAlias } from "../lib/components" + + export const loader = () => ({ title: "alias-reexport-without-source" }) + + export { + ComponentAlias as default, + HydrateFallbackAlias as HydrateFallback, + ErrorBoundaryAlias as ErrorBoundary, + } + `, +}); + +test("dev", async ({ page, dev }) => { + let { port } = await dev(files); + await workflow({ page, port }); +}); + +test("build", async ({ page, reactRouterServe }) => { + let { port } = await reactRouterServe(files); + await workflow({ page, port }); +}); + +async function workflow({ page, port }: { page: Page; port: number }) { + const routes = [ + "named-reexport-with-source", + "alias-reexport-with-source", + "named-reexport-without-source", + "alias-reexport-without-source", + ]; + for (const route of routes) { + await page.goto(`http://localhost:${port}/${route}`, { + waitUntil: "networkidle", + }); + await expect(page.locator("[data-title]")).toHaveText(route); + expect(page.errors).toEqual([]); + } +} diff --git a/packages/react-router-dev/vite/with-props.ts b/packages/react-router-dev/vite/with-props.ts index 8f12cc9082..62545acb40 100644 --- a/packages/react-router-dev/vite/with-props.ts +++ b/packages/react-router-dev/vite/with-props.ts @@ -40,8 +40,9 @@ export const decorateComponentExportsWithProps = ( const { source } = path.node; const exports: Array<{ - uid: Babel.Identifier; + specifier: NodePath; local: Babel.Identifier; + uid: Babel.Identifier; exported: Babel.Identifier; }> = []; for (const specifier of path.get("specifiers")) { @@ -51,8 +52,7 @@ export const decorateComponentExportsWithProps = ( if (!t.isIdentifier(exported)) continue; const uid = path.scope.generateUidIdentifier(`_${name}`); if (exported.name === "default" || isNamedComponentExport(name)) { - exports.push({ uid, local, exported }); - specifier.remove(); + exports.push({ specifier, local, uid, exported }); } } } @@ -67,9 +67,8 @@ export const decorateComponentExportsWithProps = ( ), ]); } else { - for (const { local, uid } of exports) { - path.scope.getBinding(local.name)?.scope.rename(uid.name); - } + const scope = path.scope.getProgramParent(); + exports.forEach(({ local, uid }) => scope.rename(local.name, uid.name)); } // `export const exported = uid` @@ -85,6 +84,8 @@ export const decorateComponentExportsWithProps = ( ); }), ); + + exports.forEach(({ specifier }) => specifier.remove()); }, }); From 89c5932e4643d01807ebcb572a0c246ae4932f9f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 26 Sep 2025 11:24:37 -0400 Subject: [PATCH 3/3] changeset --- .changeset/lovely-ants-laugh.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/lovely-ants-laugh.md diff --git a/.changeset/lovely-ants-laugh.md b/.changeset/lovely-ants-laugh.md new file mode 100644 index 0000000000..b298c64a17 --- /dev/null +++ b/.changeset/lovely-ants-laugh.md @@ -0,0 +1,21 @@ +--- +"@react-router/dev": patch +--- + +Fix framework props for reexported components + +Previously, when re-exporting the `default` component, `HydrateFallback`, or `ErrorBoundary` their corresponding framework props like `params`, `loaderData`, and `actionData` were not provided, causing errors at runtime. +Now, React Router detects re-exports for framework components and provides their corresponding props. + +For example, both of these now work: + +```ts +export { default } from "./other-module"; +``` + +```ts +function Component({ params, loaderData, actionData }) { + /* ... */ +} +export { Component as default }; +```