diff --git a/.changeset/calm-pants-film.md b/.changeset/calm-pants-film.md new file mode 100644 index 0000000000..b5d25cd536 --- /dev/null +++ b/.changeset/calm-pants-film.md @@ -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` diff --git a/integration/prefetch-test.ts b/integration/prefetch-test.ts index 6b768de1f3..2dded83363 100644 --- a/integration/prefetch-test.ts +++ b/integration/prefetch-test.ts @@ -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 [ @@ -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); }); }); @@ -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 [ @@ -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 [ @@ -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 ({ @@ -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']"); @@ -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 [ @@ -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 [ @@ -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); }); }); diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index f1ea75deb5..42e55630c9 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -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, @@ -2149,7 +2149,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { } }, }, - WithProps.plugin, { name: "react-router:route-exports", async transform(code, id, options) { @@ -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, diff --git a/packages/react-router-dev/vite/with-props.ts b/packages/react-router-dev/vite/with-props.ts index 3aabc89bdf..3420060c8d 100644 --- a/packages/react-router-dev/vite/with-props.ts +++ b/packages/react-router-dev/vite/with-props.ts @@ -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) => { +export const decorateComponentExportsWithProps = ( + ast: ParseResult +) => { 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; @@ -80,7 +32,7 @@ export const transform = (ast: ParseResult) => { 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; @@ -97,9 +49,8 @@ export const transform = (ast: ParseResult) => { 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; @@ -109,9 +60,9 @@ export const transform = (ast: ParseResult) => { 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( @@ -131,7 +82,7 @@ export const transform = (ast: ParseResult) => { hocs.map(([name, identifier]) => t.importSpecifier(identifier, t.identifier(name)) ), - t.stringLiteral(vmod.id) + t.stringLiteral("react-router") ) ); } diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index b314487b34..85e55e3706 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -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 */ diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index bff2152268..769dc28bf1 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -53,11 +53,16 @@ import { } from "./context"; import { _renderMatches, + useActionData, useAsyncValue, useInRouterContext, + useLoaderData, useLocation, + useMatches, useNavigate, useOutlet, + useParams, + useRouteError, useRoutes, useRoutesImpl, } from "./hooks"; @@ -1229,3 +1234,58 @@ export function renderMatches( ): React.ReactElement | null { return _renderMatches(matches); } + +export type RouteComponentType = React.ComponentType<{ + params: ReturnType; + loaderData: ReturnType; + actionData: ReturnType; + matches: ReturnType; +}>; + +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; + loaderData: ReturnType; + actionData: ReturnType; +}>; + +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; + loaderData: ReturnType; + actionData: ReturnType; + error: ReturnType; +}>; + +export function withErrorBoundaryProps(ErrorBoundary: ErrorBoundaryType) { + return function WithErrorBoundaryProps() { + const props = { + params: useParams(), + loaderData: useLoaderData(), + actionData: useActionData(), + error: useRouteError(), + }; + return React.createElement(ErrorBoundary, props); + }; +} diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index f21f5e250d..1fe56b7fdd 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -18,35 +18,24 @@ import type { FutureConfig, FrameworkContextObject, } from "./entry"; -import { Outlet, RouterProvider, createMemoryRouter } from "../../components"; +import { + type RouteComponentType, + type HydrateFallbackType, + type ErrorBoundaryType, + Outlet, + RouterProvider, + createMemoryRouter, + withComponentProps, + withErrorBoundaryProps, + withHydrateFallbackProps, +} from "../../components"; import type { EntryRoute } from "./routes"; import { FrameworkContext } from "./components"; -import { - useParams, - useLoaderData, - useActionData, - useMatches, - useRouteError, -} from "../../hooks"; interface StubRouteExtensions { - Component?: React.ComponentType<{ - params: ReturnType; - loaderData: ReturnType; - actionData: ReturnType; - matches: ReturnType; - }>; - HydrateFallback?: React.ComponentType<{ - params: ReturnType; - loaderData: ReturnType; - actionData: ReturnType; - }>; - ErrorBoundary?: React.ComponentType<{ - params: ReturnType; - loaderData: ReturnType; - actionData: ReturnType; - error: ReturnType; - }>; + Component?: RouteComponentType; + HydrateFallback?: HydrateFallbackType; + ErrorBoundary?: ErrorBoundaryType; loader?: LoaderFunction; action?: ActionFunction; children?: StubRouteObject[]; @@ -177,41 +166,6 @@ export function createRoutesStub( }; } -// Implementations copied from packages/react-router-dev/vite/with-props.ts -function withComponentProps(Component: React.ComponentType) { - return function Wrapped() { - return React.createElement(Component, { - params: useParams(), - loaderData: useLoaderData(), - actionData: useActionData(), - matches: useMatches(), - }); - }; -} - -function withHydrateFallbackProps(HydrateFallback: React.ComponentType) { - return function Wrapped() { - const props = { - params: useParams(), - loaderData: useLoaderData(), - actionData: useActionData(), - }; - return React.createElement(HydrateFallback, props); - }; -} - -function withErrorBoundaryProps(ErrorBoundary: React.ComponentType) { - return function Wrapped() { - const props = { - params: useParams(), - loaderData: useLoaderData(), - actionData: useActionData(), - error: useRouteError(), - }; - return React.createElement(ErrorBoundary, props); - }; -} - function processRoutes( routes: StubRouteObject[], manifest: AssetsManifest,