Skip to content

fix: move with-props runtime logic to core library #13650

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 22, 2025
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
6 changes: 6 additions & 0 deletions .changeset/calm-pants-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-router/dev": patch
"react-router": patch
---

Avoid additional `with-props` chunk in Framework Mode by moving route module component prop logic from the Vite plugin to `react-router`
32 changes: 6 additions & 26 deletions integration/prefetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,6 @@ test.describe("prefetch", () => {

// These 2 are common and duped for both - but they've already loaded on
// page load so they don't trigger network requests
await page.waitForSelector(
"link[rel='modulepreload'][href^='/assets/with-props-']",
{ state: "attached" }
);
await page.waitForSelector(
// Look for either Rollup or Rolldown chunks
[
Expand All @@ -179,7 +175,7 @@ test.describe("prefetch", () => {
);

// Ensure no other links in the #nav element
expect(await page.locator("link").count()).toBe(7);
expect(await page.locator("link").count()).toBe(5);
});
});

Expand Down Expand Up @@ -225,10 +221,6 @@ test.describe("prefetch", () => {
"link[rel='modulepreload'][href^='/assets/prefetch-with-loader-']",
{ state: "attached" }
);
await page.waitForSelector(
"link[rel='modulepreload'][href^='/assets/with-props-']",
{ state: "attached" }
);
await page.waitForSelector(
// Look for either Rollup or Rolldown chunks
[
Expand All @@ -237,17 +229,13 @@ test.describe("prefetch", () => {
].join(","),
{ state: "attached" }
);
expect(await page.locator("link").count()).toBe(4);
expect(await page.locator("link").count()).toBe(3);

await page.hover("a[href='/prefetch-without-loader']");
await page.waitForSelector(
"link[rel='modulepreload'][href^='/assets/prefetch-without-loader-']",
{ state: "attached" }
);
await page.waitForSelector(
"link[rel='modulepreload'][href^='/assets/with-props-']",
{ state: "attached" }
);
await page.waitForSelector(
// Look for either Rollup or Rolldown chunks
[
Expand All @@ -256,7 +244,7 @@ test.describe("prefetch", () => {
].join(","),
{ state: "attached" }
);
expect(await page.locator("link").count()).toBe(3);
expect(await page.locator("link").count()).toBe(2);
});

test("removes prefetch tags after navigating to/from the page", async ({
Expand All @@ -268,7 +256,7 @@ test.describe("prefetch", () => {
// Links added on hover
await page.hover("a[href='/prefetch-with-loader']");
await page.waitForSelector("link", { state: "attached" });
expect(await page.locator("link").count()).toBe(4);
expect(await page.locator("link").count()).toBe(3);

// Links removed upon navigating to the page
await page.click("a[href='/prefetch-with-loader']");
Expand Down Expand Up @@ -334,10 +322,6 @@ test.describe("prefetch", () => {
"link[rel='modulepreload'][href^='/assets/prefetch-with-loader-']",
{ state: "attached" }
);
await page.waitForSelector(
"link[rel='modulepreload'][href^='/assets/with-props-']",
{ state: "attached" }
);
await page.waitForSelector(
// Look for either Rollup or Rolldown chunks
[
Expand All @@ -346,17 +330,13 @@ test.describe("prefetch", () => {
].join(","),
{ state: "attached" }
);
expect(await page.locator("link").count()).toBe(4);
expect(await page.locator("link").count()).toBe(3);

await page.focus("a[href='/prefetch-without-loader']");
await page.waitForSelector(
"link[rel='modulepreload'][href^='/assets/prefetch-without-loader-']",
{ state: "attached" }
);
await page.waitForSelector(
"link[rel='modulepreload'][href^='/assets/with-props-']",
{ state: "attached" }
);
await page.waitForSelector(
// Look for either Rollup or Rolldown chunks
[
Expand All @@ -365,7 +345,7 @@ test.describe("prefetch", () => {
].join(","),
{ state: "attached" }
);
expect(await page.locator("link").count()).toBe(3);
expect(await page.locator("link").count()).toBe(2);
});
});

Expand Down
5 changes: 2 additions & 3 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import {
resolveEntryFiles,
configRouteToBranchRoute,
} from "../config/config";
import * as WithProps from "./with-props";
import { decorateComponentExportsWithProps } from "./with-props";

export type LoadCssContents = (
viteDevServer: Vite.ViteDevServer,
Expand Down Expand Up @@ -2149,7 +2149,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
}
},
},
WithProps.plugin,
{
name: "react-router:route-exports",
async transform(code, id, options) {
Expand Down Expand Up @@ -2201,7 +2200,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
if (!options?.ssr) {
removeExports(ast, SERVER_ONLY_ROUTE_EXPORTS);
}
WithProps.transform(ast);
decorateComponentExportsWithProps(ast);
return generate(ast, {
sourceMaps: true,
filename: id,
Expand Down
87 changes: 19 additions & 68 deletions packages/react-router-dev/vite/with-props.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,22 @@
import type { Plugin } from "vite";
import dedent from "dedent";

import type { Babel, NodePath, ParseResult } from "./babel";
import { traverse, t } from "./babel";
import * as VirtualModule from "./virtual-module";

const vmod = VirtualModule.create("with-props");

const NAMED_COMPONENT_EXPORTS = ["HydrateFallback", "ErrorBoundary"];

export const plugin: Plugin = {
name: "react-router-with-props",
enforce: "pre",
resolveId(id) {
if (id === vmod.id) return vmod.resolvedId;
},
async load(id) {
if (id !== vmod.resolvedId) return;

// Note: If you make changes to these implementations, please also update
// the corresponding functions in packages/react-router/lib/dom/ssr/routes-test-stub.tsx
return dedent`
import { createElement as h } from "react";
import { useActionData, useLoaderData, useMatches, useParams, useRouteError } from "react-router";

export function withComponentProps(Component) {
return function Wrapped() {
const props = {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
matches: useMatches(),
};
return h(Component, props);
};
}

export function withHydrateFallbackProps(HydrateFallback) {
return function Wrapped() {
const props = {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
};
return h(HydrateFallback, props);
};
}
const namedComponentExports = ["HydrateFallback", "ErrorBoundary"] as const;
type NamedComponentExport = (typeof namedComponentExports)[number];
function isNamedComponentExport(name: string): name is NamedComponentExport {
return namedComponentExports.includes(name as NamedComponentExport);
}

export function withErrorBoundaryProps(ErrorBoundary) {
return function Wrapped() {
const props = {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
error: useRouteError(),
};
return h(ErrorBoundary, props);
};
}
`;
},
};
type HocName =
| "UNSAFE_withComponentProps"
| "UNSAFE_withHydrateFallbackProps"
| "UNSAFE_withErrorBoundaryProps";

export const transform = (ast: ParseResult<Babel.File>) => {
export const decorateComponentExportsWithProps = (
ast: ParseResult<Babel.File>
) => {
const hocs: Array<[string, Babel.Identifier]> = [];
function getHocUid(path: NodePath, hocName: string) {
function getHocUid(path: NodePath, hocName: HocName) {
const uid = path.scope.generateUidIdentifier(hocName);
hocs.push([hocName, uid]);
return uid;
Expand All @@ -80,7 +32,7 @@ export const transform = (ast: ParseResult<Babel.File>) => {
declaration.isFunctionDeclaration() ? toFunctionExpression(declaration.node) :
undefined
if (expr) {
const uid = getHocUid(path, "withComponentProps");
const uid = getHocUid(path, "UNSAFE_withComponentProps");
declaration.replaceWith(t.callExpression(uid, [expr]));
}
return;
Expand All @@ -97,9 +49,8 @@ export const transform = (ast: ParseResult<Babel.File>) => {
if (!expr) return;
if (!id.isIdentifier()) return;
const { name } = id.node;
if (!NAMED_COMPONENT_EXPORTS.includes(name)) return;

const uid = getHocUid(path, `with${name}Props`);
if (!isNamedComponentExport(name)) return;
const uid = getHocUid(path, `UNSAFE_with${name}Props`);
init.replaceWith(t.callExpression(uid, [expr]));
});
return;
Expand All @@ -109,9 +60,9 @@ export const transform = (ast: ParseResult<Babel.File>) => {
const { id } = decl.node;
if (!id) return;
const { name } = id;
if (!NAMED_COMPONENT_EXPORTS.includes(name)) return;
if (!isNamedComponentExport(name)) return;

const uid = getHocUid(path, `with${name}Props`);
const uid = getHocUid(path, `UNSAFE_with${name}Props`);
decl.replaceWith(
t.variableDeclaration("const", [
t.variableDeclarator(
Expand All @@ -131,7 +82,7 @@ export const transform = (ast: ParseResult<Babel.File>) => {
hocs.map(([name, identifier]) =>
t.importSpecifier(identifier, t.identifier(name))
),
t.stringLiteral(vmod.id)
t.stringLiteral("react-router")
)
);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,9 @@ export {
export {
hydrationRouteProperties as UNSAFE_hydrationRouteProperties,
mapRouteProperties as UNSAFE_mapRouteProperties,
withComponentProps as UNSAFE_withComponentProps,
withHydrateFallbackProps as UNSAFE_withHydrateFallbackProps,
withErrorBoundaryProps as UNSAFE_withErrorBoundaryProps,
} from "./lib/components";

/** @internal */
Expand Down
60 changes: 60 additions & 0 deletions packages/react-router/lib/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ import {
} from "./context";
import {
_renderMatches,
useActionData,
useAsyncValue,
useInRouterContext,
useLoaderData,
useLocation,
useMatches,
useNavigate,
useOutlet,
useParams,
useRouteError,
useRoutes,
useRoutesImpl,
} from "./hooks";
Expand Down Expand Up @@ -1229,3 +1234,58 @@ export function renderMatches(
): React.ReactElement | null {
return _renderMatches(matches);
}

export type RouteComponentType = React.ComponentType<{
params: ReturnType<typeof useParams>;
loaderData: ReturnType<typeof useLoaderData>;
actionData: ReturnType<typeof useActionData>;
matches: ReturnType<typeof useMatches>;
}>;

export function withComponentProps(Component: RouteComponentType) {
return function WithComponentProps() {
const props = {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
matches: useMatches(),
};
return React.createElement(Component, props);
};
}

export type HydrateFallbackType = React.ComponentType<{
params: ReturnType<typeof useParams>;
loaderData: ReturnType<typeof useLoaderData>;
actionData: ReturnType<typeof useActionData>;
}>;

export function withHydrateFallbackProps(HydrateFallback: HydrateFallbackType) {
return function WithHydrateFallbackProps() {
const props = {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
};
return React.createElement(HydrateFallback, props);
};
}

export type ErrorBoundaryType = React.ComponentType<{
params: ReturnType<typeof useParams>;
loaderData: ReturnType<typeof useLoaderData>;
actionData: ReturnType<typeof useActionData>;
error: ReturnType<typeof useRouteError>;
}>;

export function withErrorBoundaryProps(ErrorBoundary: ErrorBoundaryType) {
return function WithErrorBoundaryProps() {
const props = {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
error: useRouteError(),
};
return React.createElement(ErrorBoundary, props);
};
}
Loading