Skip to content
Open
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
21 changes: 21 additions & 0 deletions .changeset/lovely-ants-laugh.md
Original file line number Diff line number Diff line change
@@ -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 };
```
117 changes: 117 additions & 0 deletions integration/framework-props-test.ts
Original file line number Diff line number Diff line change
@@ -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 <h1 data-title>{loaderData.title}</h1>;
}
export const ComponentAlias = Component;
export default Component;

export function HydrateFallback() {
return <div>Loading...</div>;
}
export const HydrateFallbackAlias = HydrateFallback;

export function ErrorBoundary() {
return <div>Error</div>;
}
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([]);
}
}
67 changes: 67 additions & 0 deletions packages/react-router-dev/vite/with-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,73 @@ export const decorateComponentExportsWithProps = (
return uid;
}

/**
* Rewrite any re-exports for named components (`default Component`, `HydrateFallback`, `ErrorBoundary`)
* into `export const <name> = <expr>` 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<{
specifier: NodePath;
local: Babel.Identifier;
uid: 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({ specifier, local, uid, exported });
}
}
}
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 {
const scope = path.scope.getProgramParent();
exports.forEach(({ local, uid }) => scope.rename(local.name, 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),
]),
);
}),
);

exports.forEach(({ specifier }) => specifier.remove());
},
});

traverse(ast, {
ExportDeclaration(path) {
if (path.isExportDefaultDeclaration()) {
Expand Down
Loading