From 6e98814571173ce13185c3bae829521d321a6a29 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 4 Apr 2025 11:35:38 -0400 Subject: [PATCH 01/25] fix(react-router): Add better error messaging when `getLoadContext` isn't updated (#13242) --- .changeset/sixty-tigers-poke.md | 5 + .../__tests__/server-runtime/server-test.ts | 231 ++++++++++++++++-- .../react-router/lib/server-runtime/server.ts | 57 +++-- 3 files changed, 256 insertions(+), 37 deletions(-) create mode 100644 .changeset/sixty-tigers-poke.md diff --git a/.changeset/sixty-tigers-poke.md b/.changeset/sixty-tigers-poke.md new file mode 100644 index 0000000000..d59eb15ed3 --- /dev/null +++ b/.changeset/sixty-tigers-poke.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +UNSTABLE: Add better error messaging when `getLoadContext` is not updated to return a `Map`" diff --git a/packages/react-router/__tests__/server-runtime/server-test.ts b/packages/react-router/__tests__/server-runtime/server-test.ts index 9301f68ee6..2c4fae9755 100644 --- a/packages/react-router/__tests__/server-runtime/server-test.ts +++ b/packages/react-router/__tests__/server-runtime/server-test.ts @@ -2,7 +2,10 @@ * @jest-environment node */ -import type { StaticHandlerContext } from "react-router"; +import { + unstable_createContext, + type StaticHandlerContext, +} from "react-router"; import { createRequestHandler } from "../../lib/server-runtime/server"; import { ServerMode } from "../../lib/server-runtime/mode"; @@ -24,7 +27,7 @@ function spyConsole() { return spy; } -describe.skip("server", () => { +describe("server", () => { let routeId = "root"; let build: ServerBuild = { ssr: true, @@ -72,20 +75,20 @@ describe.skip("server", () => { }); let allowThrough = [ - ["GET", "/"], - ["GET", "/?_data=root"], - ["POST", "/"], - ["POST", "/?_data=root"], - ["PUT", "/"], - ["PUT", "/?_data=root"], - ["DELETE", "/"], - ["DELETE", "/?_data=root"], - ["PATCH", "/"], - ["PATCH", "/?_data=root"], + ["GET", "/", "COMPONENT"], + ["GET", "/_root.data", "LOADER"], + ["POST", "/", "COMPONENT"], + ["POST", "/_root.data", "ACTION"], + ["PUT", "/", "COMPONENT"], + ["PUT", "/_root.data", "ACTION"], + ["DELETE", "/", "COMPONENT"], + ["DELETE", "/_root.data", "ACTION"], + ["PATCH", "/", "COMPONENT"], + ["PATCH", "/_root.data", "ACTION"], ]; it.each(allowThrough)( `allows through %s request to %s`, - async (method, to) => { + async (method, to, expected) => { let handler = createRequestHandler(build); let response = await handler( new Request(`http://localhost:3000${to}`, { @@ -96,11 +99,6 @@ describe.skip("server", () => { expect(response.status).toBe(200); let text = await response.text(); expect(text).toContain(method); - let expected = !to.includes("?_data=root") - ? "COMPONENT" - : method === "GET" - ? "LOADER" - : "ACTION"; expect(text).toContain(expected); expect(spy.console).not.toHaveBeenCalled(); } @@ -116,6 +114,203 @@ describe.skip("server", () => { expect(await response.text()).toBe(""); }); + + it("accepts proper values from getLoadContext (without middleware)", async () => { + let handler = createRequestHandler({ + ssr: true, + entry: { + module: { + default: async (request) => { + return new Response( + `${request.method}, ${request.url} COMPONENT` + ); + }, + }, + }, + routes: { + root: { + id: "root", + path: "", + module: { + loader: ({ context }) => context.foo, + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + root: { + clientActionModule: undefined, + clientLoaderModule: undefined, + clientMiddlewareModule: undefined, + hasAction: true, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: true, + hydrateFallbackModule: undefined, + id: routeId, + module: routeId, + path: "", + }, + }, + entry: { imports: [], module: "" }, + url: "", + version: "", + }, + future: { + unstable_middleware: false, + }, + prerender: [], + publicPath: "/", + assetsBuildDirectory: "/", + isSpaMode: false, + }); + let response = await handler( + new Request("http://localhost:3000/_root.data"), + { + foo: "FOO", + } + ); + + expect(await response.text()).toContain("FOO"); + }); + + it("accepts proper values from getLoadContext (with middleware)", async () => { + let fooContext = unstable_createContext(); + let handler = createRequestHandler({ + ssr: true, + entry: { + module: { + default: async (request) => { + return new Response( + `${request.method}, ${request.url} COMPONENT` + ); + }, + }, + }, + routes: { + root: { + id: "root", + path: "", + module: { + loader: ({ context }) => context.get(fooContext), + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + root: { + clientActionModule: undefined, + clientLoaderModule: undefined, + clientMiddlewareModule: undefined, + hasAction: true, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: true, + hydrateFallbackModule: undefined, + id: routeId, + module: routeId, + path: "", + }, + }, + entry: { imports: [], module: "" }, + url: "", + version: "", + }, + future: { + unstable_middleware: true, + }, + prerender: [], + publicPath: "/", + assetsBuildDirectory: "/", + isSpaMode: false, + }); + let response = await handler( + new Request("http://localhost:3000/_root.data"), + // @ts-expect-error In apps the expected type is handled via the Future interface + new Map([[fooContext, "FOO"]]) + ); + + expect(await response.text()).toContain("FOO"); + }); + + it("errors if an invalid value is returned from getLoadContext (with middleware)", async () => { + let handleErrorSpy = jest.fn(); + let handler = createRequestHandler({ + ssr: true, + entry: { + module: { + handleError: handleErrorSpy, + default: async (request) => { + return new Response( + `${request.method}, ${request.url} COMPONENT` + ); + }, + }, + }, + routes: { + root: { + id: "root", + path: "", + module: { + loader: ({ context }) => context.foo, + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + root: { + clientActionModule: undefined, + clientLoaderModule: undefined, + clientMiddlewareModule: undefined, + hasAction: true, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: true, + hydrateFallbackModule: undefined, + id: routeId, + module: routeId, + path: "", + }, + }, + entry: { imports: [], module: "" }, + url: "", + version: "", + }, + future: { + unstable_middleware: true, + }, + prerender: [], + publicPath: "/", + assetsBuildDirectory: "/", + isSpaMode: false, + }); + + let response = await handler( + new Request("http://localhost:3000/_root.data"), + { + foo: "FOO", + } + ); + + expect(response.status).toBe(500); + expect(await response.text()).toContain("Unexpected Server Error"); + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + [Error: Unable to create initial \`unstable_RouterContextProvider\` instance. Please confirm you are returning an instance of \`Map\` from your \`getLoadContext\` function. + + Error: TypeError: init is not iterable] + `); + handleErrorSpy.mockRestore(); + }); }); }); diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 0d1aa6a3e1..84c0bac3c9 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -90,12 +90,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( return async function requestHandler(request, initialContext) { _build = typeof build === "function" ? await build() : build; - let loadContext = _build.future.unstable_middleware - ? new unstable_RouterContextProvider( - initialContext as unknown as unstable_InitialContext - ) - : initialContext || {}; - if (typeof build === "function") { let derived = derive(_build, mode); routes = derived.routes; @@ -110,6 +104,44 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( errorHandler = derived.errorHandler; } + let params: RouteMatch["params"] = {}; + let loadContext: AppLoadContext | unstable_RouterContextProvider; + + let handleError = (error: unknown) => { + if (mode === ServerMode.Development) { + getDevServerHooks()?.processRequestError?.(error); + } + + errorHandler(error, { + context: loadContext, + params, + request, + }); + }; + + if (_build.future.unstable_middleware) { + if (initialContext == null) { + loadContext = new unstable_RouterContextProvider(); + } else { + try { + loadContext = new unstable_RouterContextProvider( + initialContext as unknown as unstable_InitialContext + ); + } catch (e) { + let error = new Error( + "Unable to create initial `unstable_RouterContextProvider` instance. " + + "Please confirm you are returning an instance of " + + "`Map` from your `getLoadContext` function." + + `\n\nError: ${e instanceof Error ? e.toString() : e}` + ); + handleError(error); + return returnLastResortErrorResponse(error, serverMode); + } + } + } else { + loadContext = initialContext || {}; + } + let url = new URL(request.url); let normalizedBasename = _build.basename || "/"; @@ -127,19 +159,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( normalizedPath = normalizedPath.slice(0, -1); } - let params: RouteMatch["params"] = {}; - let handleError = (error: unknown) => { - if (mode === ServerMode.Development) { - getDevServerHooks()?.processRequestError?.(error); - } - - errorHandler(error, { - context: loadContext, - params, - request, - }); - }; - // When runtime SSR is disabled, make our dev server behave like the deployed // pre-rendered site would if (!_build.ssr) { From c923dedc4ad484a70ae61431406d2b2f4bfbdb58 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 7 Apr 2025 15:34:22 -0400 Subject: [PATCH 02/25] Properly handle redirects in prerendered pages (#13365) --- .changeset/smart-ligers-lay.md | 6 +++ integration/vite-prerender-test.ts | 43 +++++++++++++++++++ packages/react-router-dev/vite/plugin.ts | 29 ++++++++++++- .../react-router/lib/server-runtime/routes.ts | 39 +++++++++++++---- 4 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 .changeset/smart-ligers-lay.md diff --git a/.changeset/smart-ligers-lay.md b/.changeset/smart-ligers-lay.md new file mode 100644 index 0000000000..ca831ce832 --- /dev/null +++ b/.changeset/smart-ligers-lay.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Fix prerendering when a loader returns a redirect diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index c6b0c6d18c..afa014be4a 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -54,6 +54,7 @@ let files = { Home
About
Not Found
+ Redirect
{children} @@ -2347,5 +2348,47 @@ test.describe("Prerendering", () => { await page.waitForSelector("[data-error]:has-text('404 Not Found')"); expect(requests).toEqual(["/not-found.data"]); }); + + test("Handles redirects in prerendered pages", async ({ page }) => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: true, + }), + "app/routes/redirect.tsx": js` + import { redirect } from "react-router" + export function loader() { + return redirect('/target', 301); + } + export default function Component() { +

Nope

+ } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + + // Document loads + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + await page.waitForSelector("#target"); + expect(requests).toEqual([]); + + // Client side navigations + await app.goto("/", true); + app.clickLink("/redirect"); + await page.waitForSelector("#target"); + expect(requests).toEqual(["/redirect.data"]); + }); }); }); diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 96b51e1711..543e03ec7b 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -2761,7 +2761,8 @@ async function prerenderData( let response = await handler(request); let data = await response.text(); - if (response.status !== 200) { + // 202 is used for `.data` redirects + if (response.status !== 200 && response.status !== 202) { throw new Error( `Prerender (data): Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${prerenderPath}\` ` + @@ -2780,6 +2781,8 @@ async function prerenderData( return data; } +let redirectStatusCodes = new Set([301, 302, 303, 307, 308]); + async function prerenderRoute( handler: RequestHandler, prerenderPath: string, @@ -2796,7 +2799,29 @@ async function prerenderRoute( let response = await handler(request); let html = await response.text(); - if (response.status !== 200) { + if (redirectStatusCodes.has(response.status)) { + // This isn't ideal but gets the job done as a fallback if the user can't + // implement proper redirects via .htaccess or something else. This is the + // approach used by Astro as well so there's some precedent. + // https://github.com/withastro/roadmap/issues/466 + // https://github.com/withastro/astro/blob/main/packages/astro/src/core/routing/3xx.ts + let location = response.headers.get("Location"); + // A short delay causes Google to interpret the redirect as temporary. + // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh + let delay = response.status === 302 ? 2 : 0; + html = ` + +Redirecting to: ${location} + + + + + + Redirecting from ${normalizedPath} to ${location} + + +`; + } else if (response.status !== 200) { throw new Error( `Prerender (html): Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index a49682f572..e7bcf80978 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -2,9 +2,11 @@ import type { AgnosticDataRouteObject, LoaderFunctionArgs as RRLoaderFunctionArgs, ActionFunctionArgs as RRActionFunctionArgs, + RedirectFunction, RouteManifest, unstable_MiddlewareFunction, } from "../router/utils"; +import { redirectDocument, replace, redirect } from "../router/utils"; import { callRouteHandler } from "./data"; import type { FutureConfig } from "../dom/ssr/entry"; import type { Route } from "../dom/ssr/routes"; @@ -12,7 +14,10 @@ import type { SingleFetchResult, SingleFetchResults, } from "../dom/ssr/single-fetch"; -import { decodeViaTurboStream } from "../dom/ssr/single-fetch"; +import { + SingleFetchRedirectSymbol, + decodeViaTurboStream, +} from "../dom/ssr/single-fetch"; import invariant from "./invariant"; import type { ServerRouteModule } from "../dom/ssr/routeModules"; @@ -99,13 +104,31 @@ export function createStaticHandlerDataRoutes( }); let decoded = await decodeViaTurboStream(stream, global); let data = decoded.value as SingleFetchResults; - invariant( - data && route.id in data, - "Unable to decode prerendered data" - ); - let result = data[route.id] as SingleFetchResult; - invariant("data" in result, "Unable to process prerendered data"); - return result.data; + + // If the loader returned a `.data` redirect, re-throw a normal + // Response here to trigger a document level SSG redirect + if (data && SingleFetchRedirectSymbol in data) { + let result = data[SingleFetchRedirectSymbol]!; + let init = { status: result.status }; + if (result.reload) { + throw redirectDocument(result.redirect, init); + } else if (result.replace) { + throw replace(result.redirect, init); + } else { + throw redirect(result.redirect, init); + } + } else { + invariant( + data && route.id in data, + "Unable to decode prerendered data" + ); + let result = data[route.id] as SingleFetchResult; + invariant( + "data" in result, + "Unable to process prerendered data" + ); + return result.data; + } } let val = await callRouteHandler(route.module.loader!, args); return val; From 9f07f8143c47451185b18f2396bea2f6da0bf9d4 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 7 Apr 2025 15:36:42 -0400 Subject: [PATCH 03/25] Revalidate prerendered paths when param values change (#13380) * Revalidate prerendered paths when param values change * Slight clenaup --- .changeset/orange-sloths-tease.md | 5 + integration/vite-prerender-test.ts | 749 ++++++++++++------- packages/react-router/lib/dom/ssr/routes.tsx | 29 +- packages/react-router/lib/router/utils.ts | 2 +- 4 files changed, 499 insertions(+), 286 deletions(-) create mode 100644 .changeset/orange-sloths-tease.md diff --git a/.changeset/orange-sloths-tease.md b/.changeset/orange-sloths-tease.md new file mode 100644 index 0000000000..6fe10ad2d1 --- /dev/null +++ b/.changeset/orange-sloths-tease.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Properly revalidate prerendered paths when param values change diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index afa014be4a..cdfd46e151 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -170,38 +170,38 @@ test.describe("Prerendering", () => { files: { ...files, "app/routes/parent.tsx": js` - import { Outlet } from 'react-router' - export default function Component() { - return - } - `, + import { Outlet } from 'react-router' + export default function Component() { + return + } + `, "app/routes/parent.child.tsx": js` - import { Outlet } from 'react-router' - export function loader() { - return null; - } - export default function Component() { - return - } - `, + import { Outlet } from 'react-router' + export function loader() { + return null; + } + export default function Component() { + return + } + `, "app/routes/$slug.tsx": js` - import { Outlet } from 'react-router' - export function loader() { - return null; - } - export default function Component() { - return - } - `, + import { Outlet } from 'react-router' + export function loader() { + return null; + } + export default function Component() { + return + } + `, "app/routes/$.tsx": js` - import { Outlet } from 'react-router' - export function loader() { - return null; - } - export default function Component() { - return - } - `, + import { Outlet } from 'react-router' + export function loader() { + return null; + } + export default function Component() { + return + } + `, }, }); @@ -240,24 +240,24 @@ test.describe("Prerendering", () => { files: { ...files, "react-router.config.ts": js` - export default { - async prerender() { - await new Promise(r => setTimeout(r, 1)); - return ['/', '/about']; - }, - } - `, + export default { + async prerender() { + await new Promise(r => setTimeout(r, 1)); + return ['/', '/about']; + }, + } + `, "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [ - reactRouter() - ], - }); - `, + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter() + ], + }); + `, }, }); appFixture = await createAppFixture(fixture); @@ -292,26 +292,26 @@ test.describe("Prerendering", () => { files: { ...files, "react-router.config.ts": js` - let counter = 1; - export default { - serverBundles: () => "server" + counter++, - async prerender() { - await new Promise(r => setTimeout(r, 1)); - return ['/', '/about']; - }, - } - `, + let counter = 1; + export default { + serverBundles: () => "server" + counter++, + async prerender() { + await new Promise(r => setTimeout(r, 1)); + return ['/', '/about']; + }, + } + `, "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [ - reactRouter() - ], - }); - `, + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter() + ], + }); + `, }, }); appFixture = await createAppFixture(fixture); @@ -345,29 +345,29 @@ test.describe("Prerendering", () => { files: { ...files, "react-router.config.ts": js` - export default { - async prerender({ getStaticPaths }) { - return [...getStaticPaths(), "/a", "/b"]; - }, - } - `, + export default { + async prerender({ getStaticPaths }) { + return [...getStaticPaths(), "/a", "/b"]; + }, + } + `, "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/$slug.tsx": js` - export function loader() { - return null - } - export default function component() { - return null; - } - `, + export function loader() { + return null + } + export default function component() { + return null; + } + `, }, }); appFixture = await createAppFixture(fixture); @@ -444,19 +444,19 @@ test.describe("Prerendering", () => { files: { ...files, "app/routes/text[.txt].tsx": js` - export function loader() { - return new Response("Hello, world"); - } - `, + export function loader() { + return new Response("Hello, world"); + } + `, "app/routes/json[.json].tsx": js` - export function loader() { - return new Response(JSON.stringify({ hello: 'world' }), { - headers: { - 'Content-Type': 'application/json', - } - }); - } - `, + export function loader() { + return new Response(JSON.stringify({ hello: 'world' }), { + headers: { + 'Content-Type': 'application/json', + } + }); + } + `, "app/routes/image[.png].tsx": js` export function loader() { return new Response( @@ -532,24 +532,24 @@ test.describe("Prerendering", () => { files: { ...files, "react-router.config.ts": js` - export default { - async prerender() { - await new Promise(r => setTimeout(r, 1)); - return ['/', 'about']; - }, - } - `, + export default { + async prerender() { + await new Promise(r => setTimeout(r, 1)); + return ['/', 'about']; + }, + } + `, "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [ - reactRouter() - ], - }); - `, + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter() + ], + }); + `, }, }); appFixture = await createAppFixture(fixture); @@ -591,36 +591,36 @@ test.describe("Prerendering", () => { prerender: ["/", "/about"], }), "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/about.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return "ABOUT-" + request.headers.has('X-React-Router-Prerender'); - } - - export default function Comp() { - let data = useLoaderData(); - return

About: {data}

- } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return "ABOUT-" + request.headers.has('X-React-Router-Prerender'); + } + + export default function Comp() { + let data = useLoaderData(); + return

About: {data}

+ } + `, "app/routes/not-prerendered.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return "NOT-PRERENDERED-" + request.headers.has('X-React-Router-Prerender'); - } - - export default function Comp() { - let data = useLoaderData(); - return

Not-Prerendered: {data}

- } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return "NOT-PRERENDERED-" + request.headers.has('X-React-Router-Prerender'); + } + + export default function Comp() { + let data = useLoaderData(); + return

Not-Prerendered: {data}

+ } + `, }, }); appFixture = await createAppFixture(fixture); @@ -647,35 +647,35 @@ test.describe("Prerendering", () => { prerender: ["/", "/about"], }), "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/about.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', - // 24999 characters - data: new Array(5000).fill('test').join('-'), - }; - } - - export default function Comp() { - let data = useLoaderData(); - return ( - <> -

Large loader

-

{data.prerendered}

-

{data.data.length}

- - ); - } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return { + prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + // 24999 characters + data: new Array(5000).fill('test').join('-'), + }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

Large loader

+

{data.prerendered}

+

{data.data.length}

+ + ); + } + `, }, }); appFixture = await createAppFixture(fixture); @@ -700,54 +700,54 @@ test.describe("Prerendering", () => { prerender: ["/", "/utf8-prerendered"], }), "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/utf8-prerendered.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', - data: "한글 데이터 - UTF-8 문자", - }; - } - - export default function Comp() { - let data = useLoaderData(); - return ( - <> -

UTF-8 Prerendered

-

{data.prerendered}

-

{data.data}

- - ); - } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return { + prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + data: "한글 데이터 - UTF-8 문자", + }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

UTF-8 Prerendered

+

{data.prerendered}

+

{data.data}

+ + ); + } + `, "app/routes/utf8-not-prerendered.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', - data: "非プリレンダリングデータ - UTF-8文字", - }; - } - - export default function Comp() { - let data = useLoaderData(); - return ( - <> -

UTF-8 Not Prerendered

-

{data.prerendered}

-

{data.data}

- - ); - } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return { + prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + data: "非プリレンダリングデータ - UTF-8文字", + }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

UTF-8 Not Prerendered

+

{data.prerendered}

+

{data.data}

+ + ); + } + `, }, }); appFixture = await createAppFixture(fixture); @@ -783,47 +783,47 @@ test.describe("Prerendering", () => { prerender: ["/", "/parent", "/parent/child"], }), "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/parent.tsx": js` - import { Outlet, useLoaderData } from 'react-router'; - export function loader() { - return "PARENT"; - } - export default function Comp() { - let data = useLoaderData(); - return <>

Parent: {data}

- } - `, + import { Outlet, useLoaderData } from 'react-router'; + export function loader() { + return "PARENT"; + } + export default function Comp() { + let data = useLoaderData(); + return <>

Parent: {data}

+ } + `, "app/routes/parent.child.tsx": js` - import { Outlet, useLoaderData } from 'react-router'; - export function loader() { - return "CHILD"; - } - export function HydrateFallback() { - return

Child loading...

- } - export default function Comp() { - let data = useLoaderData(); - return <>

Child: {data}

- } - `, + import { Outlet, useLoaderData } from 'react-router'; + export function loader() { + return "CHILD"; + } + export function HydrateFallback() { + return

Child loading...

+ } + export default function Comp() { + let data = useLoaderData(); + return <>

Child: {data}

+ } + `, "app/routes/parent.child._index.tsx": js` - import { Outlet, useLoaderData } from 'react-router'; - export function clientLoader() { - return "INDEX"; - } - export default function Comp() { - let data = useLoaderData(); - return <>

Index: {data}

- } - `, + import { Outlet, useLoaderData } from 'react-router'; + export function clientLoader() { + return "INDEX"; + } + export default function Comp() { + let data = useLoaderData(); + return <>

Index: {data}

+ } + `, }, }); appFixture = await createAppFixture(fixture); @@ -854,6 +854,12 @@ test.describe("Prerendering", () => { return requests; } + function clearRequests(requests: string[]) { + while (requests.length) { + requests.pop(); + } + } + test("Errors on headers/action functions in any route", async () => { let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ @@ -1207,43 +1213,45 @@ test.describe("Prerendering", () => { expect(await (await page.$("[data-page]"))?.innerText()).toBe( "PAGE DATA" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 1" ); + // No revalidation after submission to self + expect(requests).toEqual([]); await app.clickLink("/page2"); await page.waitForSelector("[data-page2]"); expect(await (await page.$("[data-page2]"))?.innerText()).toBe( "PAGE2 DATA" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 1" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 2" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 2" ); - - // We should only make this call when navigating to the prerendered route - // 2 calls (no revalidation after submission to self): - // - ✅ Initial navigation - // - ❌ No revalidation after submission to self - // - ✅ After submission back from /page - expect(requests).toEqual(["/page.data", "/page.data"]); + expect(requests).toEqual([]); }); test("Navigates across SPA/prerender pages when starting from a prerendered page", async ({ @@ -1346,43 +1354,45 @@ test.describe("Prerendering", () => { expect(await (await page.$("[data-page]"))?.innerText()).toBe( "PAGE DATA" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 1" ); + // No revalidation after submission to self + expect(requests).toEqual([]); await app.clickLink("/page2"); await page.waitForSelector("[data-page2]"); expect(await (await page.$("[data-page2]"))?.innerText()).toBe( "PAGE2 DATA" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 1" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 2" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 2" ); - - // We should only make this call when navigating to the prerendered route - // 2 calls (no revalidation after submission to self): - // - ✅ Initial navigation - // - ❌ No revalidation after submission to self - // - ✅ After submission back from /page - expect(requests).toEqual(["/page.data", "/page.data"]); + expect(requests).toEqual([]); }); test("Navigates across SPA/prerender pages when starting from a SPA page and a root loader exists", async ({ @@ -1497,43 +1507,45 @@ test.describe("Prerendering", () => { expect(await (await page.$("[data-page]"))?.innerText()).toBe( "PAGE DATA" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 1" ); + // No revalidation after submission to self + expect(requests).toEqual([]); await app.clickLink("/page2"); await page.waitForSelector("[data-page2]"); expect(await (await page.$("[data-page2]"))?.innerText()).toBe( "PAGE2 DATA" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 1" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 2" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 2" ); - - // We should only make this call when navigating to the prerendered route - // 2 calls (no revalidation after submission to self): - // - ✅ Initial navigation - // - ❌ No revalidation after submission to self - // - ✅ After submission back from /page - expect(requests).toEqual(["/page.data", "/page.data"]); + expect(requests).toEqual([]); }); test("Navigates across SPA/prerender pages when starting from a prerendered page and a root loader exists", async ({ @@ -1648,43 +1660,45 @@ test.describe("Prerendering", () => { expect(await (await page.$("[data-page]"))?.innerText()).toBe( "PAGE DATA" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 1" ); + // No revalidation after submission to self + expect(requests).toEqual([]); await app.clickLink("/page2"); await page.waitForSelector("[data-page2]"); expect(await (await page.$("[data-page2]"))?.innerText()).toBe( "PAGE2 DATA" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 1" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 2" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 2" ); - - // We should only make this call when navigating to the prerendered route - // 2 calls (no revalidation after submission to self): - // - ✅ Initial navigation - // - ❌ No revalidation after submission to self - // - ✅ After submission back from /page - expect(requests).toEqual(["/page.data", "/page.data"]); + expect(requests).toEqual([]); }); test("Navigates between prerendered parent and child SPA route", async ({ @@ -2242,6 +2256,185 @@ test.describe("Prerendering", () => { expect(requests).toEqual(["/parent/child.data"]); }); + test("Navigates prerender pages when params exist", async ({ page }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: ["/", "/page", "/param/1", "/param/2"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` + import * as React from "react"; + import { Link, Outlet, Scripts, useNavigation } from "react-router"; + + export function Layout({ children }) { + let navigation = useNavigation(); + return ( + + + + +

{navigation.state}

+ {children} + + + + ); + } + + export default function Root({ loaderData }) { + return + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + "app/routes/page.tsx": js` + export function loader() { + return "PAGE DATA" + } + export default function Page({ loaderData }) { + return

{loaderData}

; + } + `, + "app/routes/param.$id.tsx": js` + export function loader({ params }) { + return params.id; + } + export default function Page({ loaderData }) { + return

Param {loaderData}

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA" + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); + + await app.clickLink("/page"); + await page.waitForSelector("#navigation-idle"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA" + ); + // No revalidation since page.data is static + expect(requests).toEqual([]); + + await app.clickLink("/param/1"); + await page.waitForSelector('[data-param="1"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1"); + console.log("asserting", requests); + expect(requests).toEqual(["/param/1.data"]); + clearRequests(requests); + + await app.clickLink("/param/2"); + await page.waitForSelector('[data-param="2"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 2"); + expect(requests).toEqual(["/param/2.data"]); + clearRequests(requests); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA" + ); + expect(requests).toEqual(["/page.data"]); + }); + + test("Returns a 404 if navigating to a non-prerendered param value", async ({ + page, + }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: ["/param/1"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` + import * as React from "react"; + import { Link, Outlet, Scripts, useNavigation } from "react-router"; + + export function Layout({ children }) { + let navigation = useNavigation(); + return ( + + + + +

{navigation.state}

+ {children} + + + + ); + } + + export default function Root({ loaderData }) { + return + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + "app/routes/param.$id.tsx": js` + export function loader({ params }) { + return params.id; + } + export default function Page({ loaderData }) { + return

Param {loaderData}

; + } + + export function ErrorBoundary({ error }) { + return

{error.status}

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/param/1"); + await page.waitForSelector('[data-param="1"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1"); + console.log("asserting", requests); + expect(requests).toEqual(["/param/1.data"]); + clearRequests(requests); + + await app.clickLink("/param/404"); + await page.waitForSelector('[data-error="404"]'); + expect(requests).toEqual(["/param/404.data"]); + }); + test("Navigates to prerendered parent with clientLoader calling loader", async ({ page, }) => { diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 27cb9feb2f..b9ed35559e 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -9,7 +9,7 @@ import type { ShouldRevalidateFunction, ShouldRevalidateFunctionArgs, } from "../../router/utils"; -import { ErrorResponseImpl } from "../../router/utils"; +import { ErrorResponseImpl, compilePath } from "../../router/utils"; import type { RouteModule, RouteModules } from "./routeModules"; import { loadRouteModule } from "./routeModules"; import type { FutureConfig } from "./entry"; @@ -312,6 +312,7 @@ export function createClientRoutes( unstable_middleware: routeModule.unstable_clientMiddleware, handle: routeModule.handle, shouldRevalidate: getShouldRevalidateFunction( + dataRoute.path, routeModule, route, ssr, @@ -524,6 +525,7 @@ export function createClientRoutes( shouldRevalidate: async () => { let lazyRoute = await getLazyRoute(); return getShouldRevalidateFunction( + dataRoute.path, lazyRoute, route, ssr, @@ -556,6 +558,7 @@ export function createClientRoutes( } function getShouldRevalidateFunction( + path: string | undefined, route: Partial, manifestRoute: Omit, ssr: boolean, @@ -572,17 +575,29 @@ function getShouldRevalidateFunction( // When prerendering is enabled with `ssr:false`, any `loader` data is // statically generated at build time so if we have a `loader` but not a - // `clientLoader`, we disable revalidation by default since we can't be sure - // if a `.data` file was pre-rendered. If users are somehow re-generating - // updated versions of these on the backend they can still opt-into - // revalidation which will make the `.data` request + // `clientLoader`, we only revalidate if the route's params changed since we + // can't be sure if a `.data` file was pre-rendered otherwise. + // + // I.e., If I have a parent and a child route and I only prerender `/parent`, + // we can't have parent revalidate when going from `/parent -> /parent/child` + // because `/parent/child.data` doesn't exist. + // + // If users are somehow re-generating updated versions of these on the backend + // they can still opt-into revalidation which will make the `.data` request if (!ssr && manifestRoute.hasLoader && !manifestRoute.hasClientLoader) { + let myParams = path ? compilePath(path)[1].map((p) => p.paramName) : []; + const didParamsChange = (opts: ShouldRevalidateFunctionArgs) => + myParams.some((p) => opts.currentParams[p] !== opts.nextParams[p]); + if (route.shouldRevalidate) { let fn = route.shouldRevalidate; return (opts: ShouldRevalidateFunctionArgs) => - fn({ ...opts, defaultShouldRevalidate: false }); + fn({ + ...opts, + defaultShouldRevalidate: didParamsChange(opts), + }); } else { - return () => false; + return (opts: ShouldRevalidateFunctionArgs) => didParamsChange(opts); } } diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index c375d3c42c..b20d2323f5 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1206,7 +1206,7 @@ export function matchPath< type CompiledPathParam = { paramName: string; isOptional?: boolean }; -function compilePath( +export function compilePath( path: string, caseSensitive = false, end = true From a87b7960cc76486fee662edb4c9bc7f82d45898c Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 8 Apr 2025 12:20:23 +1000 Subject: [PATCH 04/25] Refactor lazy tests (#13383) --- .../__tests__/router/data-strategy-test.ts | 36 +- .../__tests__/router/lazy-test.ts | 1089 ++++++++--------- .../router/utils/data-router-setup.ts | 19 +- 3 files changed, 522 insertions(+), 622 deletions(-) diff --git a/packages/react-router/__tests__/router/data-strategy-test.ts b/packages/react-router/__tests__/router/data-strategy-test.ts index 4ef1cd2b98..5f7bf94ab0 100644 --- a/packages/react-router/__tests__/router/data-strategy-test.ts +++ b/packages/react-router/__tests__/router/data-strategy-test.ts @@ -5,7 +5,7 @@ import type { } from "../../lib/router/utils"; import { createDeferred, - createLazyStub, + createAsyncStub, setup, } from "./utils/data-router-setup"; import { createFormData, tick } from "./utils/utils"; @@ -99,10 +99,8 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ) ); - let { lazyStub: lazyJsonStub, lazyDeferred: lazyJsonDeferred } = - createLazyStub(); - let { lazyStub: lazyTextStub, lazyDeferred: lazyTextDeferred } = - createLazyStub(); + let [lazyJson, lazyJsonDeferred] = createAsyncStub(); + let [lazyText, lazyTextDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -111,12 +109,12 @@ describe("router dataStrategy", () => { { id: "json", path: "/test", - lazy: lazyJsonStub, + lazy: lazyJson, children: [ { id: "text", index: true, - lazy: lazyTextStub, + lazy: lazyText, }, ], }, @@ -219,7 +217,7 @@ describe("router dataStrategy", () => { }); it("should allow custom implementations to override default behavior with lazy", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -228,7 +226,7 @@ describe("router dataStrategy", () => { { id: "test", path: "/test", - lazy: lazyStub, + lazy, }, ], async dataStrategy({ matches }) { @@ -374,7 +372,7 @@ describe("router dataStrategy", () => { }); it("does not require resolve to be called if a match is not being loaded", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -389,7 +387,7 @@ describe("router dataStrategy", () => { { id: "child", path: "child", - lazy: lazyStub, + lazy, }, ], }, @@ -451,7 +449,7 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ); }); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -468,7 +466,7 @@ describe("router dataStrategy", () => { { id: "child", path: "child", - lazy: lazyStub, + lazy, }, ], }, @@ -632,7 +630,7 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ) ); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -641,7 +639,7 @@ describe("router dataStrategy", () => { { id: "json", path: "/test", - lazy: lazyStub, + lazy, }, ], dataStrategy, @@ -721,7 +719,7 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ) ); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -730,7 +728,7 @@ describe("router dataStrategy", () => { { id: "json", path: "/test", - lazy: lazyStub, + lazy, }, ], dataStrategy, @@ -808,7 +806,7 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ) ); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -817,7 +815,7 @@ describe("router dataStrategy", () => { { id: "json", path: "/test", - lazy: lazyStub, + lazy, }, ], dataStrategy, diff --git a/packages/react-router/__tests__/router/lazy-test.ts b/packages/react-router/__tests__/router/lazy-test.ts index 00d931ea55..3bd202a9e8 100644 --- a/packages/react-router/__tests__/router/lazy-test.ts +++ b/packages/react-router/__tests__/router/lazy-test.ts @@ -8,7 +8,7 @@ import type { import { cleanup, createDeferred, - createLazyStub, + createAsyncStub, setup, } from "./utils/data-router-setup"; import { @@ -52,13 +52,13 @@ describe("lazily loaded route modules", () => { const createBasicLazyFunctionRoutes = (): { routes: TestRouteObject[]; - lazyStub: jest.Mock; + lazy: jest.Mock; lazyDeferred: ReturnType; } => { - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); return { - routes: createBasicLazyRoutes(lazyStub), - lazyStub, + routes: createBasicLazyRoutes(lazy), + lazy, lazyDeferred, }; }; @@ -162,7 +162,7 @@ describe("lazily loaded route modules", () => { it("ignores and warns on unsupported lazy route function properties on router initialization", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderDeferred = createDeferred(); + let loaderDeferred = createDeferred(); let router = createRouter({ routes: [ { @@ -170,7 +170,7 @@ describe("lazily loaded route modules", () => { // @ts-expect-error lazy: async () => { return { - loader: () => lazyLoaderDeferred.promise, + loader: () => loaderDeferred.promise, lazy: async () => { throw new Error("SHOULD NOT BE CALLED"); }, @@ -191,7 +191,7 @@ describe("lazily loaded route modules", () => { router.initialize(); let LOADER_DATA = 123; - await lazyLoaderDeferred.resolve(LOADER_DATA); + await loaderDeferred.resolve(LOADER_DATA); expect(router.state.location.pathname).toBe("/lazy"); expect(router.state.navigation.state).toBe("idle"); @@ -216,13 +216,13 @@ describe("lazily loaded route modules", () => { it("ignores and warns on unsupported lazy route properties on router initialization", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderDeferred = createDeferred(); + let loaderDeferred = createDeferred(); let router = createRouter({ routes: [ { path: "/lazy", lazy: { - loader: () => lazyLoaderDeferred.promise, + loader: () => loaderDeferred.promise, // @ts-expect-error lazy: async () => { throw new Error("SHOULD NOT BE CALLED"); @@ -244,7 +244,7 @@ describe("lazily loaded route modules", () => { let LOADER_DATA = 123; let loader = () => LOADER_DATA; - await lazyLoaderDeferred.resolve(loader); + await loaderDeferred.resolve(loader); expect(router.state.location.pathname).toBe("/lazy"); expect(router.state.navigation.state).toBe("idle"); @@ -323,7 +323,7 @@ describe("lazily loaded route modules", () => { router.initialize(); // Ensure loader is called as soon as it's loaded - let { lazyStub: loader, lazyDeferred: loaderDeferred } = createLazyStub(); + let [loader, loaderDeferred] = createAsyncStub(); await lazyLoaderDeferred.resolve(loader); expect(loader).toHaveBeenCalledTimes(1); expect(router.state.initialized).toBe(false); @@ -346,14 +346,14 @@ describe("lazily loaded route modules", () => { describe("happy path", () => { it("fetches lazy route functions on loading navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); lazyDeferred.resolve({ @@ -361,7 +361,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await loaderDeferred.resolve("LAZY LOADER"); @@ -370,14 +370,12 @@ describe("lazily loaded route modules", () => { expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("resolves lazy route properties on loading navigation", async () => { - let { lazyStub: lazyLoader, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyAction, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ loader: lazyLoader, action: lazyAction, @@ -391,7 +389,7 @@ describe("lazily loaded route modules", () => { expect(lazyLoader).toHaveBeenCalledTimes(1); // Ensure loader is called as soon as it's loaded - let { lazyStub: loader, lazyDeferred: loaderDeferred } = createLazyStub(); + let [loader, loaderDeferred] = createAsyncStub(); await lazyLoaderDeferred.resolve(loader); expect(loader).toHaveBeenCalledTimes(1); expect(t.router.state.location.pathname).toBe("/"); @@ -417,30 +415,28 @@ describe("lazily loaded route modules", () => { }); it("ignores falsy lazy route properties on loading navigation", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - await lazyDeferred.resolve(null); + await lazyLoaderDeferred.resolve(null); expect(t.router.state.matches[0].route.loader).toBeUndefined(); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.loaderData).toEqual({}); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("fetches lazy route functions on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -448,7 +444,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); let loaderDeferred = createDeferred(); @@ -477,21 +473,19 @@ describe("lazily loaded route modules", () => { lazy: "LAZY LOADER", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("resolves lazy route properties on submission navigation", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - action: lazyActionStub, + loader: lazyLoader, + action: lazyAction, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -499,11 +493,11 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); - let { lazyStub: action, lazyDeferred: actionDeferred } = createLazyStub(); - let { lazyStub: loader, lazyDeferred: loaderDeferred } = createLazyStub(); + let [action, actionDeferred] = createAsyncStub(); + let [loader, loaderDeferred] = createAsyncStub(); // Ensure action is called as soon as it's loaded await lazyActionDeferred.resolve(action); @@ -531,22 +525,20 @@ describe("lazily loaded route modules", () => { lazy: "LAZY LOADER", }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("ignores falsy lazy route properties on submission navigation", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - action: lazyActionStub, + loader: lazyLoader, + action: lazyAction, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -554,8 +546,8 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); await lazyLoaderDeferred.resolve(undefined); await lazyActionDeferred.resolve(null); @@ -563,21 +555,21 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.actionData).toEqual(null); expect(t.router.state.loaderData).toEqual({}); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(t.router.state.matches[0].route.loader).toBeUndefined(); expect(t.router.state.matches[0].route.action).toBeUndefined(); }); it("fetches lazy route functions on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); lazyDeferred.resolve({ @@ -589,37 +581,35 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("resolves lazy route properties on fetcher.load", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); - lazyDeferred.resolve(() => loaderDeferred.promise); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDeferred.resolve("LAZY LOADER"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("fetches lazy route functions on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -627,7 +617,7 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); lazyDeferred.resolve({ @@ -639,21 +629,19 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key]?.state).toBe("idle"); expect(t.fetchers[key]?.data).toBe("LAZY ACTION"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("resolves lazy route properties on fetcher.submit", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - action: lazyActionStub, + loader: lazyLoader, + action: lazyAction, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -661,8 +649,8 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); let loaderDeferred = createDeferred(); @@ -674,8 +662,8 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key]?.state).toBe("idle"); expect(t.fetchers[key]?.data).toBe("LAZY ACTION"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("fetches lazy route functions on staticHandler.query()", async () => { @@ -768,14 +756,14 @@ describe("lazily loaded route modules", () => { describe("statically defined fields", () => { it("prefers statically defined loader over lazily defined loader via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { id: "lazy", path: "/lazy", loader: true, - lazy: lazyStub, + lazy, }, ], }); @@ -785,12 +773,10 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("loading"); // Execute in parallel expect(A.loaders.lazy.stub).toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let loader = jest.fn(() => "LAZY LOADER"); + lazyDeferred.resolve({ loader }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); @@ -801,12 +787,12 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.loader).not.toBe(lazyLoaderStub); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.loader).not.toBe(loader); + expect(loader).not.toHaveBeenCalled(); + expect(lazy).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -817,7 +803,7 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -825,7 +811,7 @@ describe("lazily loaded route modules", () => { path: "/lazy", loader: true, lazy: { - loader: lazyStub, + loader: lazyLoader, }, }, ], @@ -836,10 +822,10 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("loading"); // Execute in parallel expect(A.loaders.lazy.stub).toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(0); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - lazyDeferred.resolve(lazyLoaderStub); + let loader = jest.fn(() => "LAZY LOADER"); + lazyLoaderDeferred.resolve(loader); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); @@ -850,12 +836,12 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.loader).not.toBe(lazyLoaderStub); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(0); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.loader).not.toBe(loader); + expect(loader).not.toHaveBeenCalled(); + expect(lazyLoader).toHaveBeenCalledTimes(0); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -866,7 +852,7 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined falsy loader via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -874,7 +860,7 @@ describe("lazily loaded route modules", () => { path: "/lazy", loader: true, lazy: { - loader: lazyStub, + loader: lazyLoader, }, }, ], @@ -885,9 +871,9 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("loading"); // Execute in parallel expect(A.loaders.lazy.stub).toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(0); - lazyDeferred.resolve(null); + lazyLoaderDeferred.resolve(null); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); @@ -898,11 +884,11 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.loader).toBeInstanceOf(Function); - expect(lazyStub).toHaveBeenCalledTimes(0); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.loader).toBeInstanceOf(Function); + expect(lazyLoader).toHaveBeenCalledTimes(0); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -913,14 +899,14 @@ describe("lazily loaded route modules", () => { it("prefers statically defined action over lazily loaded action via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { id: "lazy", path: "/lazy", action: true, - lazy: lazyStub, + lazy, }, ], }); @@ -933,12 +919,12 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("submitting"); // Execute in parallel expect(A.actions.lazy.stub).toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); + let lazyAction = jest.fn(() => "LAZY ACTION"); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ - action: lazyActionStub, + action: lazyAction, loader: () => loaderDeferred.promise, }); expect(t.router.state.location.pathname).toBe("/"); @@ -962,12 +948,12 @@ describe("lazily loaded route modules", () => { lazy: "LAZY LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(lazyActionStub); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.action).toEqual(expect.any(Function)); + expect(route.action).not.toBe(lazyAction); + expect(lazyAction).not.toHaveBeenCalled(); + expect(lazy).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -978,10 +964,8 @@ describe("lazily loaded route modules", () => { it("prefers statically defined action over lazily loaded action via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -989,8 +973,8 @@ describe("lazily loaded route modules", () => { path: "/lazy", action: true, lazy: { - action: lazyActionStub, - loader: lazyLoaderStub, + action: lazyAction, + loader: lazyLoader, }, }, ], @@ -1004,12 +988,12 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("submitting"); // Execute in parallel expect(A.actions.lazy.stub).toHaveBeenCalled(); - expect(lazyActionStub).toHaveBeenCalledTimes(0); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let actionStub = jest.fn(() => "LAZY ACTION"); + let action = jest.fn(() => "LAZY ACTION"); let loaderDeferred = createDeferred(); - lazyActionDeferred.resolve(actionStub); + lazyActionDeferred.resolve(action); lazyLoaderDeferred.resolve(() => loaderDeferred.promise); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); @@ -1032,13 +1016,13 @@ describe("lazily loaded route modules", () => { lazy: "LAZY LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(actionStub); - expect(actionStub).not.toHaveBeenCalled(); - expect(lazyActionStub).toHaveBeenCalledTimes(0); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.action).toEqual(expect.any(Function)); + expect(route.action).not.toBe(action); + expect(action).not.toHaveBeenCalled(); + expect(lazyAction).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1049,7 +1033,7 @@ describe("lazily loaded route modules", () => { it("prefers statically defined action/loader over lazily defined action/loader via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -1057,7 +1041,7 @@ describe("lazily loaded route modules", () => { path: "/lazy", action: true, loader: true, - lazy: lazyStub, + lazy, }, ], }); @@ -1068,14 +1052,11 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await lazyDeferred.resolve({ - action: lazyActionStub, - loader: lazyLoaderStub, - }); + let action = jest.fn(() => "LAZY ACTION"); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyDeferred.resolve({ action, loader }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); @@ -1097,15 +1078,15 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(lazyActionStub); - expect(lazyRoute.loader).not.toBe(lazyLoaderStub); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.action).toEqual(expect.any(Function)); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.action).not.toBe(action); + expect(route.loader).not.toBe(loader); + expect(action).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); + expect(lazy).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledTimes(2); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1119,10 +1100,8 @@ describe("lazily loaded route modules", () => { it("prefers statically defined action/loader over lazily defined action/loader via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -1131,8 +1110,8 @@ describe("lazily loaded route modules", () => { action: true, loader: true, lazy: { - action: lazyActionStub, - loader: lazyLoaderStub, + action: lazyAction, + loader: lazyLoader, }, }, ], @@ -1144,13 +1123,13 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(0); - expect(lazyActionStub).toHaveBeenCalledTimes(0); - let actionStub = jest.fn(() => "LAZY ACTION"); - let loaderStub = jest.fn(() => "LAZY LOADER"); - lazyActionDeferred.resolve(actionStub); - lazyLoaderDeferred.resolve(loaderStub); + expect(lazyAction).toHaveBeenCalledTimes(0); + let action = jest.fn(() => "LAZY ACTION"); + let loader = jest.fn(() => "LAZY LOADER"); + lazyActionDeferred.resolve(action); + lazyLoaderDeferred.resolve(loader); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); @@ -1172,16 +1151,16 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(actionStub); - expect(lazyRoute.loader).not.toBe(loaderStub); - expect(actionStub).not.toHaveBeenCalled(); - expect(loaderStub).not.toHaveBeenCalled(); - expect(lazyActionStub).toHaveBeenCalledTimes(0); - expect(lazyLoaderStub).toHaveBeenCalledTimes(0); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.action).toEqual(expect.any(Function)); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.action).not.toBe(action); + expect(route.loader).not.toBe(loader); + expect(action).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); + expect(lazyAction).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(0); expect(consoleWarn).toHaveBeenCalledTimes(2); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1195,14 +1174,14 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy function (staticHandler.query)", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderStub = jest.fn(async () => { + let loader = jest.fn(async () => { await tick(); return Response.json({ value: "LAZY LOADER" }); }); - let lazyStub = jest.fn(async () => { + let lazy = jest.fn(async () => { await tick(); return { - loader: lazyLoaderStub, + loader, }; }); @@ -1214,7 +1193,7 @@ describe("lazily loaded route modules", () => { await tick(); return Response.json({ value: "STATIC LOADER" }); }, - lazy: lazyStub, + lazy, }, ]); @@ -1226,8 +1205,8 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" }, }); - expect(lazyStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazy).toHaveBeenCalledTimes(1); + expect(loader).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1238,13 +1217,13 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy property (staticHandler.query)", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let loaderStub = jest.fn(async () => { + let loader = jest.fn(async () => { await tick(); return Response.json({ value: "LAZY LOADER" }); }); - let lazyStub = jest.fn(async () => { + let lazyLoader = jest.fn(async () => { await tick(); - return loaderStub; + return loader; }); let { query } = createStaticHandler([ @@ -1256,7 +1235,7 @@ describe("lazily loaded route modules", () => { return Response.json({ value: "STATIC LOADER" }); }, lazy: { - loader: lazyStub, + loader: lazyLoader, }, }, ]); @@ -1269,8 +1248,8 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" }, }); - expect(lazyStub).not.toHaveBeenCalled(); - expect(loaderStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1281,7 +1260,7 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy function (staticHandler.queryRoute)", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderStub = jest.fn(async () => { + let loader = jest.fn(async () => { await tick(); return Response.json({ value: "LAZY LOADER" }); }); @@ -1297,7 +1276,7 @@ describe("lazily loaded route modules", () => { lazy: async () => { await tick(); return { - loader: lazyLoaderStub, + loader, }; }, }, @@ -1311,7 +1290,7 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" }, }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1322,13 +1301,13 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy property (staticHandler.queryRoute)", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let loaderStub = jest.fn(async () => { + let loader = jest.fn(async () => { await tick(); return Response.json({ value: "LAZY LOADER" }); }); - let lazyLoaderStub = jest.fn(async () => { + let lazyLoader = jest.fn(async () => { await tick(); - return loaderStub; + return loader; }); let { query } = createStaticHandler([ @@ -1340,7 +1319,7 @@ describe("lazily loaded route modules", () => { return Response.json({ value: "STATIC LOADER" }); }, lazy: { - loader: lazyLoaderStub, + loader: lazyLoader, }, }, ]); @@ -1353,8 +1332,8 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" }, }); - expect(loaderStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1365,7 +1344,7 @@ describe("lazily loaded route modules", () => { it("handles errors thrown from static loaders before lazy function has completed", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -1376,13 +1355,13 @@ describe("lazily loaded route modules", () => { id: "lazy", path: "lazy", loader: true, - lazy: lazyStub, + lazy, }, ], }, ], }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let A = await t.navigate("/lazy"); @@ -1400,13 +1379,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: "STATIC LOADER ERROR", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); consoleWarn.mockReset(); }); it("handles errors thrown from static loaders before lazy property has resolved", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let t = setup({ routes: [ { @@ -1418,14 +1398,14 @@ describe("lazily loaded route modules", () => { path: "lazy", loader: true, lazy: { - hasErrorBoundary: lazyStub, + hasErrorBoundary: lazyHasErrorBoundary, }, }, ], }, ], }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); let A = await t.navigate("/lazy"); @@ -1434,21 +1414,22 @@ describe("lazily loaded route modules", () => { // We shouldn't bubble the loader error until after this resolves // so we know if it has a boundary or not - await lazyDeferred.resolve(true); + await lazyHasErrorBoundaryDeferred.resolve(true); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.loaderData).toEqual({}); expect(t.router.state.errors).toEqual({ lazy: "STATIC LOADER ERROR", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); consoleWarn.mockReset(); }); }); it("bubbles errors thrown from static loaders before lazy property has resolved if lazy 'hasErrorBoundary' is falsy", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let t = setup({ routes: [ { @@ -1460,14 +1441,14 @@ describe("lazily loaded route modules", () => { path: "lazy", loader: true, lazy: { - hasErrorBoundary: lazyStub, + hasErrorBoundary: lazyHasErrorBoundary, }, }, ], }, ], }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); let A = await t.navigate("/lazy"); @@ -1476,22 +1457,22 @@ describe("lazily loaded route modules", () => { // We shouldn't bubble the loader error until after this resolves // so we know if it has a boundary or not - await lazyDeferred.resolve(null); + await lazyHasErrorBoundaryDeferred.resolve(null); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.loaderData).toEqual({}); expect(t.router.state.errors).toEqual({ root: "STATIC LOADER ERROR", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); consoleWarn.mockReset(); }); describe("interruptions", () => { it("runs lazily loaded route loader even if lazy function is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); @@ -1501,29 +1482,25 @@ describe("lazily loaded route modules", () => { expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyDeferred.resolve({ loader }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(1); // Ensure the lazy route object update still happened - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toBe(lazyLoaderStub); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toBe(loader); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route loader even if lazy property is interrupted", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); @@ -1533,24 +1510,24 @@ describe("lazily loaded route modules", () => { expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await lazyDeferred.resolve(lazyLoaderStub); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyLoaderDeferred.resolve(loader); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(1); // Ensure the lazy route object update still happened - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toBe(lazyLoaderStub); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toBe(loader); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route action even if lazy function is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1563,34 +1540,29 @@ describe("lazily loaded route modules", () => { expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await lazyDeferred.resolve({ - action: lazyActionStub, - loader: lazyLoaderStub, - }); + let action = jest.fn(() => "LAZY ACTION"); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyDeferred.resolve({ action, loader }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toBe(lazyActionStub); - expect(lazyRoute.loader).toBe(lazyLoaderStub); - expect(lazyStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(action).toHaveBeenCalledTimes(1); + expect(loader).not.toHaveBeenCalled(); + expect(route.lazy).toBeUndefined(); + expect(route.action).toBe(action); + expect(route.loader).toBe(loader); + expect(lazy).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route action even if lazy property is interrupted", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, - loader: lazyLoaderStub, + action: lazyAction, + loader: lazyLoader, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1603,84 +1575,78 @@ describe("lazily loaded route modules", () => { expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - let actionStub = jest.fn(() => "LAZY ACTION"); - let loaderStub = jest.fn(() => "LAZY LOADER"); - await lazyActionDeferred.resolve(actionStub); - await lazyLoaderDeferred.resolve(loaderStub); + let action = jest.fn(() => "LAZY ACTION"); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyActionDeferred.resolve(action); + await lazyLoaderDeferred.resolve(loader); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(actionStub).toHaveBeenCalledTimes(1); - expect(loaderStub).not.toHaveBeenCalled(); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toBe(actionStub); - expect(lazyRoute.loader).toBe(loaderStub); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(action).toHaveBeenCalledTimes(1); + expect(loader).not.toHaveBeenCalled(); + expect(route.lazy).toBeUndefined(); + expect(route.action).toBe(action); + expect(route.loader).toBe(loader); + expect(lazyAction).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route loader on fetcher.load() even if lazy function is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ loader }); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDeferred.resolve("LAZY LOADER"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazy).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route loader on fetcher.load() even if lazy property is interrupted", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let loaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve(loaderStub); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyLoaderDeferred.resolve(loader); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDeferred.resolve("LAZY LOADER"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(loaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route action on fetcher.submit() even if lazy function is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -1688,37 +1654,32 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key, { formMethod: "post", formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let actionDeferred = createDeferred(); - let lazyActionStub = jest.fn(() => actionDeferred.promise); - await lazyDeferred.resolve({ - action: lazyActionStub, - }); + let [action, actionDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ action }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); await actionDeferred.resolve("LAZY ACTION"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY ACTION"); - expect(lazyActionStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(2); + expect(lazy).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route action on fetcher.submit() even if lazy property is interrupted", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - action: lazyStub, - }); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ action: lazyAction }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -1726,48 +1687,44 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key, { formMethod: "post", formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); - let actionDeferred = createDeferred(); - let lazyActionStub = jest.fn(() => actionDeferred.promise); - await lazyDeferred.resolve(lazyActionStub); + let [action, actionDeferred] = createAsyncStub(); + await lazyActionDeferred.resolve(action); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); await actionDeferred.resolve("LAZY ACTION"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY ACTION"); - expect(lazyActionStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(2); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy function execution on repeated loading navigations", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ loader }); await loaderDeferred.resolve("LAZY LOADER"); @@ -1776,31 +1733,28 @@ describe("lazily loaded route modules", () => { expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazy).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy property execution on repeated loading navigations", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve(lazyLoaderStub); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyLoaderDeferred.resolve(loader); await loaderDeferred.resolve("LAZY LOADER"); @@ -1809,14 +1763,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy function execution on repeated submission navigations", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1824,7 +1778,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.navigate("/lazy", { formMethod: "post", @@ -1832,16 +1786,11 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let actionDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - let lazyActionStub = jest.fn(() => actionDeferred.promise); - await lazyDeferred.resolve({ - action: lazyActionStub, - loader: lazyLoaderStub, - }); + let [action, actionDeferred] = createAsyncStub(); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ action, loader }); await actionDeferred.resolve("LAZY ACTION"); await loaderDeferred.resolve("LAZY LOADER"); @@ -1852,23 +1801,21 @@ describe("lazily loaded route modules", () => { expect(t.router.state.actionData).toEqual({ lazy: "LAZY ACTION" }); expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); - expect(lazyActionStub).toHaveBeenCalledTimes(2); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy function property on repeated submission navigations", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, - loader: lazyLoaderStub, + action: lazyAction, + loader: lazyLoader, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1876,8 +1823,8 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await t.navigate("/lazy", { formMethod: "post", @@ -1885,15 +1832,13 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let actionDeferred = createDeferred(); - let loaderStub = jest.fn(() => loaderDeferred.promise); - let actionStub = jest.fn(() => actionDeferred.promise); - await lazyActionDeferred.resolve(actionStub); - await lazyLoaderDeferred.resolve(loaderStub); + let [action, actionDeferred] = createAsyncStub(); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyActionDeferred.resolve(action); + await lazyLoaderDeferred.resolve(loader); await actionDeferred.resolve("LAZY ACTION"); await loaderDeferred.resolve("LAZY LOADER"); @@ -1904,31 +1849,28 @@ describe("lazily loaded route modules", () => { expect(t.router.state.actionData).toEqual({ lazy: "LAZY ACTION" }); expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); - expect(actionStub).toHaveBeenCalledTimes(2); - expect(loaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy function execution on repeated fetcher.load calls", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ loader }); expect(t.fetchers[key].state).toBe("loading"); @@ -1936,30 +1878,27 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazy).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy property execution on repeated fetcher.load calls", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve(lazyLoaderStub); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyLoaderDeferred.resolve(loader); expect(t.fetchers[key].state).toBe("loading"); @@ -1967,8 +1906,8 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); }); @@ -2003,7 +1942,7 @@ describe("lazily loaded route modules", () => { }); it("handles errors when failing to resolve lazy route property on initialization", async () => { - let lazyDeferred = createDeferred(); + let lazyLoaderDeferred = createDeferred(); let router = createRouter({ history: createMemoryHistory({ initialEntries: ["/lazy"] }), routes: [ @@ -2016,7 +1955,7 @@ describe("lazily loaded route modules", () => { id: "lazy", path: "lazy", lazy: { - loader: () => lazyDeferred.promise, + loader: () => lazyLoaderDeferred.promise, }, }, ], @@ -2025,7 +1964,7 @@ describe("lazily loaded route modules", () => { }).initialize(); expect(router.state.initialized).toBe(false); - lazyDeferred.reject(new Error("LAZY PROPERTY ERROR")); + lazyLoaderDeferred.reject(new Error("LAZY PROPERTY ERROR")); await tick(); expect(router.state.errors).toEqual({ root: new Error("LAZY PROPERTY ERROR"), @@ -2034,14 +1973,14 @@ describe("lazily loaded route modules", () => { }); it("handles errors when failing to resolve lazy route function on loading navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); @@ -2051,23 +1990,21 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles errors when failing to resolve lazy route loader property on loading navigation", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - await lazyDeferred.reject(new Error("LAZY PROPERTY ERROR")); + await lazyLoaderDeferred.reject(new Error("LAZY PROPERTY ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); @@ -2075,14 +2012,12 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY PROPERTY ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("handles errors when failing to resolve other lazy route properties on loading navigation", async () => { - let { lazyStub: lazyLoader, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyAction, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ loader: lazyLoader, action: lazyAction, @@ -2116,14 +2051,14 @@ describe("lazily loaded route modules", () => { }); it("handles loader errors from lazy route functions when the route has an error boundary", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2132,7 +2067,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); @@ -2140,36 +2075,33 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles loader errors from lazy route properties when the route has an error boundary", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { - lazyStub: lazyHasErrorBoundaryStub, - lazyDeferred: lazyHasErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - hasErrorBoundary: lazyHasErrorBoundaryStub, + loader: lazyLoader, + hasErrorBoundary: lazyHasErrorBoundary, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); await lazyHasErrorBoundaryDeferred.resolve(() => true); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); @@ -2177,19 +2109,19 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: new Error("LAZY LOADER ERROR"), }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from in lazy route functions when the route does not specify an error boundary", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2205,24 +2137,22 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from in lazy route properties when the route does not specify an error boundary", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); - await lazyDeferred.resolve(() => loaderDeferred.promise); + await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); @@ -2233,18 +2163,18 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from lazy route functions when the route specifies hasErrorBoundary:false", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2261,29 +2191,26 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from lazy route properties when the route specifies hasErrorBoundary:false", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { - lazyStub: lazyHasErrorBoundaryStub, - lazyDeferred: lazyHasErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - hasErrorBoundary: lazyHasErrorBoundaryStub, + loader: lazyLoader, + hasErrorBoundary: lazyHasErrorBoundary, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); @@ -2298,30 +2225,27 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from lazy route properties when the route specifies hasErrorBoundary:null", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { - lazyStub: lazyHasErrorBoundaryStub, - lazyDeferred: lazyHasErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - hasErrorBoundary: lazyHasErrorBoundaryStub, + loader: lazyLoader, + hasErrorBoundary: lazyHasErrorBoundary, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); @@ -2336,14 +2260,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); }); it("handles errors when failing to resolve lazy route functions on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -2351,7 +2275,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); @@ -2362,16 +2286,14 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.actionData).toEqual(null); expect(t.router.state.loaderData).toEqual({}); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles errors when failing to resolve lazy route properties on submission navigation", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - action: lazyStub, - }); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ action: lazyAction }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -2379,9 +2301,9 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); - await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + await lazyActionDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); @@ -2390,13 +2312,13 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.actionData).toEqual(null); expect(t.router.state.loaderData).toEqual({}); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("handles action errors from lazy route functions on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -2404,7 +2326,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2421,22 +2343,19 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles action errors from lazy route properties on submission navigation", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { - lazyStub: lazyErrorBoundaryStub, - lazyDeferred: lazyErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyErrorBoundaryStub, lazyErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, + action: lazyAction, hasErrorBoundary: lazyErrorBoundaryStub, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); await t.navigate("/lazy", { @@ -2445,7 +2364,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); @@ -2461,14 +2380,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: new Error("LAZY ACTION ERROR"), }); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); }); it("bubbles action errors from lazy route functions when the route specifies hasErrorBoundary:false", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -2476,7 +2395,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2493,22 +2412,19 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("bubbles action errors from lazy route properties when the route specifies hasErrorBoundary:false", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { - lazyStub: lazyErrorBoundaryStub, - lazyDeferred: lazyErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyErrorBoundaryStub, lazyErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, + action: lazyAction, hasErrorBoundary: lazyErrorBoundaryStub, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); await t.navigate("/lazy", { @@ -2517,7 +2433,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); @@ -2533,23 +2449,20 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); }); it("bubbles action errors from lazy route properties when the route specifies hasErrorBoundary:null", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { - lazyStub: lazyErrorBoundaryStub, - lazyDeferred: lazyErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyErrorBoundaryStub, lazyErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, + action: lazyAction, hasErrorBoundary: lazyErrorBoundaryStub, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); await t.navigate("/lazy", { @@ -2558,7 +2471,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); @@ -2574,58 +2487,56 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); }); it("handles errors when failing to load lazy route functions on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles errors when failing to load lazy route properties on fetcher.load", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + await lazyLoaderDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("handles loader errors in lazy route functions on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2638,24 +2549,22 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles loader errors in lazy route properties on fetcher.load", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); - await lazyDeferred.resolve(() => loaderDeferred.promise); + await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); @@ -2663,13 +2572,13 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("handles errors when failing to load lazy route functions on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -2677,23 +2586,21 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles errors when failing to load lazy route properties on fetcher.submit", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - action: lazyStub, - }); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ action: lazyAction }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -2701,20 +2608,20 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); - await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + await lazyActionDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("handles action errors in lazy route functions on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -2722,7 +2629,7 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2736,16 +2643,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles action errors in lazy route properties on fetcher.submit", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - action: lazyStub, - }); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ action: lazyAction }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -2753,10 +2658,10 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); - await lazyDeferred.resolve(() => actionDeferred.promise); + await lazyActionDeferred.resolve(() => actionDeferred.promise); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); await actionDeferred.reject(new Error("LAZY ACTION ERROR")); @@ -2765,7 +2670,7 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("throws when failing to resolve lazy route functions on staticHandler.query()", async () => { diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts index f219cbb58d..f4c253c419 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -168,17 +168,14 @@ export function createDeferred() { }; } -export function createLazyStub(): { - lazyStub: jest.Mock; - lazyDeferred: ReturnType; -} { - let lazyDeferred = createDeferred(); - let lazyStub = jest.fn(() => lazyDeferred.promise); - - return { - lazyStub, - lazyDeferred, - }; +export function createAsyncStub(): [ + asyncStub: jest.Mock, + deferred: ReturnType +] { + let deferred = createDeferred(); + let asyncStub = jest.fn(() => deferred.promise); + + return [asyncStub, deferred]; } export function getFetcherData(router: Router) { From c63b5091ab5f15416fbf1ead8d578a2e88869555 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 9 Apr 2025 00:19:26 +1000 Subject: [PATCH 05/25] Skip `route.lazy` hydration properties after hydration (#13376) * Skip `route.lazy` hydration properties after hydration * Refactor to use `lazyRoutePropertiesToSkip` array * Refactor to make `initialHydration` an optional arg * Update tests --- .changeset/happy-spoons-watch.md | 21 +++ .../__tests__/router/lazy-test.ts | 173 ++++++++++++++++++ .../router/utils/data-router-setup.ts | 3 + packages/react-router/index.ts | 5 +- packages/react-router/lib/components.tsx | 6 + .../lib/dom-export/hydrated-router.tsx | 2 + packages/react-router/lib/dom/lib.tsx | 8 +- packages/react-router/lib/router/router.ts | 50 +++-- 8 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 .changeset/happy-spoons-watch.md diff --git a/.changeset/happy-spoons-watch.md b/.changeset/happy-spoons-watch.md new file mode 100644 index 0000000000..7ca3078b00 --- /dev/null +++ b/.changeset/happy-spoons-watch.md @@ -0,0 +1,21 @@ +--- +"react-router": patch +--- + +When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration. + +If you move the code for these properties into a separate file, you can use this optimization to avoid downloading unused hydration code. For example: + +```ts +createBrowserRouter([ + { + path: "/show/:showId", + lazy: { + loader: async () => (await import("./show.loader.js")).loader, + Component: async () => (await import("./show.component.js")).Component, + HydrateFallback: async () => + (await import("./show.hydrate-fallback.js")).HydrateFallback, + }, + }, +]); +``` diff --git a/packages/react-router/__tests__/router/lazy-test.ts b/packages/react-router/__tests__/router/lazy-test.ts index 3bd202a9e8..a9aa77244f 100644 --- a/packages/react-router/__tests__/router/lazy-test.ts +++ b/packages/react-router/__tests__/router/lazy-test.ts @@ -1,5 +1,9 @@ import { createMemoryHistory } from "../../lib/router/history"; import { createRouter, createStaticHandler } from "../../lib/router/router"; +import { + createMemoryRouter, + hydrationRouteProperties, +} from "../../lib/components"; import type { TestNonIndexRouteObject, @@ -561,6 +565,69 @@ describe("lazily loaded route modules", () => { expect(t.router.state.matches[0].route.action).toBeUndefined(); }); + it("only resolves lazy hydration route properties on hydration", async () => { + let [lazyLoaderForHydration, lazyLoaderDeferredForHydration] = + createAsyncStub(); + let [lazyLoaderForNavigation, lazyLoaderDeferredForNavigation] = + createAsyncStub(); + let [ + lazyHydrateFallbackForHydration, + lazyHydrateFallbackDeferredForHydration, + ] = createAsyncStub(); + let [ + lazyHydrateFallbackElementForHydration, + lazyHydrateFallbackElementDeferredForHydration, + ] = createAsyncStub(); + let lazyHydrateFallbackForNavigation = jest.fn(async () => null); + let lazyHydrateFallbackElementForNavigation = jest.fn(async () => null); + let router = createMemoryRouter( + [ + { + path: "/hydration", + lazy: { + HydrateFallback: lazyHydrateFallbackForHydration, + hydrateFallbackElement: lazyHydrateFallbackElementForHydration, + loader: lazyLoaderForHydration, + }, + }, + { + path: "/navigation", + lazy: { + HydrateFallback: lazyHydrateFallbackForNavigation, + hydrateFallbackElement: lazyHydrateFallbackElementForNavigation, + loader: lazyLoaderForNavigation, + }, + }, + ], + { + initialEntries: ["/hydration"], + } + ); + expect(router.state.initialized).toBe(false); + + expect(lazyHydrateFallbackForHydration).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallbackElementForHydration).toHaveBeenCalledTimes(1); + expect(lazyLoaderForHydration).toHaveBeenCalledTimes(1); + await lazyHydrateFallbackDeferredForHydration.resolve(null); + await lazyHydrateFallbackElementDeferredForHydration.resolve(null); + await lazyLoaderDeferredForHydration.resolve(null); + + expect(router.state.location.pathname).toBe("/hydration"); + expect(router.state.navigation.state).toBe("idle"); + expect(router.state.initialized).toBe(true); + + let navigationPromise = router.navigate("/navigation"); + expect(router.state.location.pathname).toBe("/hydration"); + expect(router.state.navigation.state).toBe("loading"); + expect(lazyHydrateFallbackForNavigation).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElementForNavigation).not.toHaveBeenCalled(); + expect(lazyLoaderForNavigation).toHaveBeenCalledTimes(1); + await lazyLoaderDeferredForNavigation.resolve(null); + await navigationPromise; + expect(router.state.location.pathname).toBe("/navigation"); + expect(router.state.navigation.state).toBe("idle"); + }); + it("fetches lazy route functions on fetcher.load", async () => { let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); @@ -606,6 +673,40 @@ describe("lazily loaded route modules", () => { expect(lazyLoader).toHaveBeenCalledTimes(1); }); + it("skips lazy hydration route properties on fetcher.load", async () => { + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let lazyHydrateFallback = jest.fn(async () => null); + let lazyHydrateFallbackElement = jest.fn(async () => null); + let routes = createBasicLazyRoutes({ + loader: lazyLoader, + // @ts-expect-error + HydrateFallback: lazyHydrateFallback, + hydrateFallbackElement: lazyHydrateFallbackElement, + }); + let t = setup({ routes, hydrationRouteProperties }); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("http://github.com/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); + + let loaderDeferred = createDeferred(); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await loaderDeferred.resolve("LAZY LOADER"); + expect(t.fetchers[key].state).toBe("idle"); + expect(t.fetchers[key].data).toBe("LAZY LOADER"); + + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); + }); + it("fetches lazy route functions on fetcher.submit", async () => { let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); @@ -666,6 +767,49 @@ describe("lazily loaded route modules", () => { expect(lazyAction).toHaveBeenCalledTimes(1); }); + it("skips lazy hydration route properties on fetcher.submit", async () => { + let [lazyLoaderStub, lazyLoaderDeferred] = createAsyncStub(); + let [lazyActionStub, lazyActionDeferred] = createAsyncStub(); + let lazyHydrateFallback = jest.fn(async () => null); + let lazyHydrateFallbackElement = jest.fn(async () => null); + let routes = createBasicLazyRoutes({ + loader: lazyLoaderStub, + action: lazyActionStub, + // @ts-expect-error + HydrateFallback: lazyHydrateFallback, + hydrateFallbackElement: lazyHydrateFallbackElement, + }); + let t = setup({ routes, hydrationRouteProperties }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyActionStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("http://github.com/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); + + let actionDeferred = createDeferred(); + let loaderDeferred = createDeferred(); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + lazyActionDeferred.resolve(() => actionDeferred.promise); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + + await actionDeferred.resolve("LAZY ACTION"); + expect(t.fetchers[key]?.state).toBe("idle"); + expect(t.fetchers[key]?.data).toBe("LAZY ACTION"); + + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); + }); + it("fetches lazy route functions on staticHandler.query()", async () => { let { query } = createStaticHandler([ { @@ -751,6 +895,35 @@ describe("lazily loaded route modules", () => { let data = await response.json(); expect(data).toEqual({ value: "LAZY LOADER" }); }); + + it("resolves lazy hydration route properties on staticHandler.queryRoute()", async () => { + let lazyHydrateFallback = jest.fn(async () => null); + let lazyHydrateFallbackElement = jest.fn(async () => null); + let { queryRoute } = createStaticHandler( + [ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + await tick(); + return () => Response.json({ value: "LAZY LOADER" }); + }, + // @ts-expect-error + HydrateFallback: lazyHydrateFallback, + hydrateFallbackElement: lazyHydrateFallbackElement, + }, + }, + ], + { hydrationRouteProperties } + ); + + let response = await queryRoute(createRequest("/lazy")); + let data = await response.json(); + expect(data).toEqual({ value: "LAZY LOADER" }); + expect(lazyHydrateFallback).toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).toHaveBeenCalled(); + }); }); describe("statically defined fields", () => { diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts index f4c253c419..b097054049 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -139,6 +139,7 @@ type SetupOpts = { basename?: string; initialEntries?: InitialEntry[]; initialIndex?: number; + hydrationRouteProperties?: string[]; hydrationData?: HydrationState; dataStrategy?: DataStrategyFunction; }; @@ -204,6 +205,7 @@ export function setup({ basename, initialEntries, initialIndex, + hydrationRouteProperties, hydrationData, dataStrategy, }: SetupOpts) { @@ -319,6 +321,7 @@ export function setup({ basename, history, routes: enhanceRoutes(routes), + hydrationRouteProperties, hydrationData, window: testWindow, dataStrategy: dataStrategy, diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 2fc07c9e73..f72fd36a32 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -317,7 +317,10 @@ export { } from "./lib/context"; /** @internal */ -export { mapRouteProperties as UNSAFE_mapRouteProperties } from "./lib/components"; +export { + hydrationRouteProperties as UNSAFE_hydrationRouteProperties, + mapRouteProperties as UNSAFE_mapRouteProperties, +} from "./lib/components"; /** @internal */ export { FrameworkContext as UNSAFE_FrameworkContext } from "./lib/dom/ssr/components"; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 18f52b93f2..f0a7346e47 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -131,6 +131,11 @@ export function mapRouteProperties(route: RouteObject) { return updates; } +export const hydrationRouteProperties: (keyof RouteObject)[] = [ + "HydrateFallback", + "hydrateFallbackElement", +]; + export interface MemoryRouterOpts { /** * Basename path for the application. @@ -194,6 +199,7 @@ export function createMemoryRouter( }), hydrationData: opts?.hydrationData, routes, + hydrationRouteProperties, mapRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 388820ccd7..47e2511672 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -21,6 +21,7 @@ import { UNSAFE_shouldHydrateRouteLoader as shouldHydrateRouteLoader, UNSAFE_useFogOFWarDiscovery as useFogOFWarDiscovery, UNSAFE_mapRouteProperties as mapRouteProperties, + UNSAFE_hydrationRouteProperties as hydrationRouteProperties, UNSAFE_createClientRoutesWithHMRRevalidationOptOut as createClientRoutesWithHMRRevalidationOptOut, matchRoutes, } from "react-router"; @@ -201,6 +202,7 @@ function createHydratedRouter({ basename: ssrInfo.context.basename, unstable_getContext, hydrationData, + hydrationRouteProperties, mapRouteProperties, future: { unstable_middleware: ssrInfo.context.future.unstable_middleware, diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 50a456ef9f..e280d79691 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -66,7 +66,11 @@ import { mergeRefs, usePrefetchBehavior, } from "./ssr/components"; -import { Router, mapRouteProperties } from "../components"; +import { + Router, + mapRouteProperties, + hydrationRouteProperties, +} from "../components"; import type { RouteObject, NavigateOptions, @@ -186,6 +190,7 @@ export function createBrowserRouter( hydrationData: opts?.hydrationData || parseHydrationData(), routes, mapRouteProperties, + hydrationRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, @@ -209,6 +214,7 @@ export function createHashRouter( hydrationData: opts?.hydrationData || parseHydrationData(), routes, mapRouteProperties, + hydrationRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index d785af7917..b847b7da31 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -379,6 +379,7 @@ export interface RouterInit { unstable_getContext?: () => MaybePromise; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial; + hydrationRouteProperties?: string[]; hydrationData?: HydrationState; window?: Window; dataStrategy?: DataStrategyFunction; @@ -817,6 +818,7 @@ export function createRouter(init: RouterInit): Router { "You must provide a non-empty routes array to createRouter" ); + let hydrationRouteProperties = init.hydrationRouteProperties || []; let mapRouteProperties = init.mapRouteProperties || defaultMapRouteProperties; // Routes keyed by ID @@ -2027,7 +2029,8 @@ export function createRouter(init: RouterInit): Router { matchesToLoad, revalidatingFetchers, request, - scopedContext + scopedContext, + initialHydration ); if (request.signal.aborted) { @@ -2779,10 +2782,14 @@ export function createRouter(init: RouterInit): Router { matchesToLoad: AgnosticDataRouteMatch[], matches: AgnosticDataRouteMatch[], scopedContext: unstable_RouterContextProvider, - fetcherKey: string | null + fetcherKey: string | null, + initialHydration?: boolean ): Promise> { let results: Record; let dataResults: Record = {}; + let lazyRoutePropertiesToSkip = initialHydration + ? [] + : hydrationRouteProperties; try { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, @@ -2793,7 +2800,8 @@ export function createRouter(init: RouterInit): Router { fetcherKey, manifest, mapRouteProperties, - scopedContext + scopedContext, + lazyRoutePropertiesToSkip ); } catch (e) { // If the outer dataStrategy method throws, just return the error for all @@ -2835,7 +2843,8 @@ export function createRouter(init: RouterInit): Router { matchesToLoad: AgnosticDataRouteMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request, - scopedContext: unstable_RouterContextProvider + scopedContext: unstable_RouterContextProvider, + initialHydration?: boolean ) { // Kick off loaders and fetchers in parallel let loaderResultsPromise = callDataStrategy( @@ -2844,7 +2853,8 @@ export function createRouter(init: RouterInit): Router { matchesToLoad, matches, scopedContext, - null + null, + initialHydration ); let fetcherResultsPromise = Promise.all( @@ -2856,7 +2866,8 @@ export function createRouter(init: RouterInit): Router { [f.match], f.matches, scopedContext, - f.key + f.key, + initialHydration ); let result = results[f.match.route.id]; // Fetcher results are keyed by fetcher key from here on out, not routeId @@ -4944,7 +4955,8 @@ function loadLazyRoute( route: AgnosticDataRouteObject, type: "loader" | "action", manifest: RouteManifest, - mapRouteProperties: MapRoutePropertiesFunction + mapRouteProperties: MapRoutePropertiesFunction, + lazyRoutePropertiesToSkip?: string[] ): { lazyRoutePromise: Promise | undefined; lazyHandlerPromise: Promise | undefined; @@ -5054,6 +5066,10 @@ function loadLazyRoute( let lazyHandlerPromise: Promise | undefined = undefined; for (let key of lazyKeys) { + if (lazyRoutePropertiesToSkip && lazyRoutePropertiesToSkip.includes(key)) { + continue; + } + let promise = loadLazyRouteProperty({ key, route, @@ -5068,9 +5084,12 @@ function loadLazyRoute( } } - let lazyRoutePromise = Promise.all(lazyPropertyPromises) - // Ensure type is Promise, not Promise - .then(() => {}); + let lazyRoutePromise = + lazyPropertyPromises.length > 0 + ? Promise.all(lazyPropertyPromises) + // Ensure type is Promise, not Promise + .then(() => {}) + : undefined; return { lazyRoutePromise, @@ -5290,7 +5309,8 @@ async function callDataStrategyImpl( fetcherKey: string | null, manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, - scopedContext: unknown + scopedContext: unknown, + lazyRoutePropertiesToSkip?: string[] ): Promise> { // Ensure all lazy/lazyMiddleware async functions are kicked off in parallel // before we await them where needed below @@ -5300,7 +5320,13 @@ async function callDataStrategyImpl( mapRouteProperties ); let lazyRoutePromises = matches.map((m) => - loadLazyRoute(m.route, type, manifest, mapRouteProperties) + loadLazyRoute( + m.route, + type, + manifest, + mapRouteProperties, + lazyRoutePropertiesToSkip + ) ); // Ensure all middleware is loaded before we start executing routes From 710626fe346a552b382b3d7c6be5e1990d9dd394 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 8 Apr 2025 17:02:56 -0400 Subject: [PATCH 06/25] refactor(react-router): internal data strategy (#13253) --- .changeset/fair-weeks-beam.md | 5 + .changeset/yellow-mangos-impress.md | 5 + integration/single-fetch-test.ts | 74 ++ integration/vite-prerender-test.ts | 2 - .../__tests__/router/data-strategy-test.ts | 86 +++ .../lib/dom-export/hydrated-router.tsx | 1 - .../react-router/lib/dom/ssr/single-fetch.tsx | 68 +- packages/react-router/lib/router/router.ts | 712 +++++++++++------- packages/react-router/lib/router/utils.ts | 17 + 9 files changed, 665 insertions(+), 305 deletions(-) create mode 100644 .changeset/fair-weeks-beam.md create mode 100644 .changeset/yellow-mangos-impress.md diff --git a/.changeset/fair-weeks-beam.md b/.changeset/fair-weeks-beam.md new file mode 100644 index 0000000000..91058cfe09 --- /dev/null +++ b/.changeset/fair-weeks-beam.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route diff --git a/.changeset/yellow-mangos-impress.md b/.changeset/yellow-mangos-impress.md new file mode 100644 index 0000000000..4071485a2b --- /dev/null +++ b/.changeset/yellow-mangos-impress.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy` diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 51a670b208..358922905d 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -408,6 +408,80 @@ test.describe("single-fetch", () => { ]); }); + test("revalidates on reused routes by default", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return Go to Parent + } + `, + "app/routes/parent.tsx": js` + import { Link, Outlet } from "react-router"; + import type { Route } from "./+types/parent"; + + let count = 0; + export function loader() { + return ++count; + } + + export default function Parent({ loaderData }: Route.ComponentProps) { + return ( + <> +

PARENT:{loaderData}

+ Go to Parent
+ Go to Child + + + ); + } + `, + "app/routes/parent.child.tsx": js` + import { Outlet } from "react-router"; + import type { Route } from "./+types/parent"; + + export function loader() { + return "CHILD" + } + + export default function Parent({ loaderData }: Route.ComponentProps) { + return

{loaderData}

+ } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (req.method() === "GET" && url.pathname.endsWith(".data")) { + urls.push(url.pathname + url.search); + } + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + await app.clickLink("/parent"); + await page.waitForSelector('[data-parent="1"]'); + expect(urls).toEqual(["/parent.data"]); + urls.length = 0; + + await app.clickLink("/parent/child"); + await page.waitForSelector("[data-child]"); + await expect(page.locator('[data-parent="2"]')).toBeDefined(); + expect(urls).toEqual(["/parent/child.data"]); + urls.length = 0; + + await app.clickLink("/parent"); + await page.waitForSelector('[data-parent="3"]'); + expect(urls).toEqual(["/parent.data"]); + urls.length = 0; + }); + test("does not revalidate on 4xx/5xx action responses", async ({ page }) => { let fixture = await createFixture({ files: { diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index cdfd46e151..182271a085 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -2341,7 +2341,6 @@ test.describe("Prerendering", () => { await app.clickLink("/param/1"); await page.waitForSelector('[data-param="1"]'); expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1"); - console.log("asserting", requests); expect(requests).toEqual(["/param/1.data"]); clearRequests(requests); @@ -2426,7 +2425,6 @@ test.describe("Prerendering", () => { await app.clickLink("/param/1"); await page.waitForSelector('[data-param="1"]'); expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1"); - console.log("asserting", requests); expect(requests).toEqual(["/param/1.data"]); clearRequests(requests); diff --git a/packages/react-router/__tests__/router/data-strategy-test.ts b/packages/react-router/__tests__/router/data-strategy-test.ts index 5f7bf94ab0..eb385fe4ae 100644 --- a/packages/react-router/__tests__/router/data-strategy-test.ts +++ b/packages/react-router/__tests__/router/data-strategy-test.ts @@ -577,6 +577,92 @@ describe("router dataStrategy", () => { child: "CHILD", }); }); + + it("does not short circuit when there are no matchesToLoad", async () => { + let dataStrategy = mockDataStrategy(async ({ matches }) => { + let results = await Promise.all( + matches.map((m) => m.resolve((handler) => handler())) + ); + // Don't use keyedResults since it checks for shouldLoad and this test + // is always loading + return results.reduce( + (acc, r, i) => Object.assign(acc, { [matches[i].route.id]: r }), + {} + ); + }); + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "/parent", + loader: true, + children: [ + { + id: "child", + path: "child", + loader: true, + }, + ], + }, + ], + dataStrategy, + }); + + let A = await t.navigate("/parent"); + await A.loaders.parent.resolve("PARENT1"); + expect(A.loaders.parent.stub).toHaveBeenCalled(); + expect(t.router.state.loaderData).toEqual({ + parent: "PARENT1", + }); + expect(dataStrategy.mock.calls[0][0].matches).toEqual([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "parent", + }), + }), + ]); + + let B = await t.navigate("/parent/child"); + await B.loaders.parent.resolve("PARENT2"); + await B.loaders.child.resolve("CHILD"); + expect(B.loaders.parent.stub).toHaveBeenCalled(); + expect(B.loaders.child.stub).toHaveBeenCalled(); + expect(t.router.state.loaderData).toEqual({ + parent: "PARENT2", + child: "CHILD", + }); + expect(dataStrategy.mock.calls[1][0].matches).toEqual([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "parent", + }), + }), + expect.objectContaining({ + route: expect.objectContaining({ + id: "child", + }), + }), + ]); + + let C = await t.navigate("/parent"); + await C.loaders.parent.resolve("PARENT3"); + expect(C.loaders.parent.stub).toHaveBeenCalled(); + expect(t.router.state.loaderData).toEqual({ + parent: "PARENT3", + }); + expect(dataStrategy.mock.calls[2][0].matches).toEqual([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "parent", + }), + }), + ]); + + expect(dataStrategy).toHaveBeenCalledTimes(3); + }); }); describe("actions", () => { diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 47e2511672..4b8efc84c7 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -209,7 +209,6 @@ function createHydratedRouter({ }, dataStrategy: getSingleFetchDataStrategy( ssrInfo.manifest, - ssrInfo.routeModules, ssrInfo.context.ssr, ssrInfo.context.basename, () => router diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 6471847f3e..c2bb6d5319 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -139,7 +139,6 @@ function handleMiddlewareError(error: unknown, routeId: string) { export function getSingleFetchDataStrategy( manifest: AssetsManifest, - routeModules: RouteModules, ssr: boolean, basename: string | undefined, getRouter: () => DataRouter @@ -152,12 +151,11 @@ export function getSingleFetchDataStrategy( return runMiddlewarePipeline( args, false, - () => singleFetchActionStrategy(request, matches, basename), + () => singleFetchActionStrategy(args, basename), handleMiddlewareError ) as Promise>; } - // TODO: Enable middleware for this flow if (!ssr) { // If this is SPA mode, there won't be any loaders below root and we'll // disable single fetch. We have to keep the `dataStrategy` defined for @@ -193,7 +191,7 @@ export function getSingleFetchDataStrategy( // the other end let foundRevalidatingServerLoader = matches.some( (m) => - m.shouldLoad && + m.unstable_shouldCallHandler() && manifest.routes[m.route.id]?.hasLoader && !manifest.routes[m.route.id]?.hasClientLoader ); @@ -201,7 +199,7 @@ export function getSingleFetchDataStrategy( return runMiddlewarePipeline( args, false, - () => nonSsrStrategy(manifest, request, matches, basename), + () => nonSsrStrategy(args, manifest, basename), handleMiddlewareError ) as Promise>; } @@ -223,12 +221,10 @@ export function getSingleFetchDataStrategy( false, () => singleFetchLoaderNavigationStrategy( + args, manifest, - routeModules, ssr, getRouter(), - request, - matches, basename ), handleMiddlewareError @@ -239,11 +235,10 @@ export function getSingleFetchDataStrategy( // Actions are simple since they're singular calls to the server for both // navigations and fetchers) async function singleFetchActionStrategy( - request: Request, - matches: DataStrategyFunctionArgs["matches"], + { request, matches }: DataStrategyFunctionArgs, basename: string | undefined ) { - let actionMatch = matches.find((m) => m.shouldLoad); + let actionMatch = matches.find((m) => m.unstable_shouldCallHandler()); invariant(actionMatch, "No action match found"); let actionStatus: number | undefined = undefined; let result = await actionMatch.resolve(async (handler) => { @@ -276,12 +271,11 @@ async function singleFetchActionStrategy( // We want to opt-out of Single Fetch when we aren't in SSR mode async function nonSsrStrategy( + { request, matches }: DataStrategyFunctionArgs, manifest: AssetsManifest, - request: Request, - matches: DataStrategyFunctionArgs["matches"], basename: string | undefined ) { - let matchesToLoad = matches.filter((m) => m.shouldLoad); + let matchesToLoad = matches.filter((m) => m.unstable_shouldCallHandler()); let url = stripIndexParam(singleFetchUrl(request.url, basename)); let init = await createRequestInit(request); let results: Record = {}; @@ -308,12 +302,10 @@ async function nonSsrStrategy( // Loaders are trickier since we only want to hit the server once, so we // create a singular promise for all server-loader routes to latch onto. async function singleFetchLoaderNavigationStrategy( + { request, matches }: DataStrategyFunctionArgs, manifest: AssetsManifest, - routeModules: RouteModules, ssr: boolean, router: DataRouter, - request: Request, - matches: DataStrategyFunctionArgs["matches"], basename: string | undefined ) { // Track which routes need a server load - in case we need to tack on a @@ -348,32 +340,20 @@ async function singleFetchLoaderNavigationStrategy( let manifestRoute = manifest.routes[m.route.id]; - // Note: If this logic changes for routes that should not participate - // in Single Fetch, make sure you update getLowestLoadingIndex above - // as well - if (!m.shouldLoad) { - // If we're not yet initialized and this is the initial load, respect - // `shouldLoad` because we're only dealing with `clientLoader.hydrate` - // routes which will fall into the `clientLoader` section below. - if (!router.state.initialized) { - return; - } - - // Otherwise, we opt out if we currently have data and a - // `shouldRevalidate` function. This implies that the user opted out - // via `shouldRevalidate` - if ( - m.route.id in router.state.loaderData && - manifestRoute && - m.route.shouldRevalidate - ) { - if (manifestRoute.hasLoader) { - // If we have a server loader, make sure we don't include it in the - // single fetch .data request - foundOptOutRoute = true; - } - return; - } + let defaultShouldRevalidate = + !m.unstable_shouldRevalidateArgs || + m.unstable_shouldRevalidateArgs.actionStatus == null || + m.unstable_shouldRevalidateArgs.actionStatus < 400; + let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate); + + if (!shouldCall) { + // If this route opted out of revalidation, we don't want to include + // it in the single fetch .data request + foundOptOutRoute ||= + m.unstable_shouldRevalidateArgs != null && // This is a revalidation, + manifestRoute?.hasLoader === true && // for a route with a server loader, + m.route.shouldRevalidate != null; // and a shouldRevalidate function + return; } // When a route has a client loader, it opts out of the singular call and @@ -469,7 +449,7 @@ async function singleFetchLoaderFetcherStrategy( matches: DataStrategyFunctionArgs["matches"], basename: string | undefined ) { - let fetcherMatch = matches.find((m) => m.shouldLoad); + let fetcherMatch = matches.find((m) => m.unstable_shouldCallHandler()); invariant(fetcherMatch, "No fetcher match found"); let result = await fetcherMatch.resolve(async (handler) => { let url = stripIndexParam(singleFetchUrl(request.url, basename)); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index b847b7da31..f66e4263dd 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,3 +1,4 @@ +import type { DataRouteMatch } from "../context"; import type { History, Location, Path, To } from "./history"; import { Action as NavigationType, @@ -729,7 +730,8 @@ interface FetchLoadMatch { interface RevalidatingFetcher extends FetchLoadMatch { key: string; match: AgnosticDataRouteMatch | null; - matches: AgnosticDataRouteMatch[] | null; + matches: DataStrategyMatch[] | null; + request: Request | null; controller: AbortController | null; } @@ -1630,6 +1632,7 @@ export function createRouter(init: RouterInit): Router { matches, scopedContext, fogOfWar.active, + opts && opts.initialHydration === true, { replace: opts.replace, flushSync } ); @@ -1721,6 +1724,7 @@ export function createRouter(init: RouterInit): Router { matches: AgnosticDataRouteMatch[], scopedContext: unstable_RouterContextProvider, isFogOfWar: boolean, + initialHydration: boolean, opts: { replace?: boolean; flushSync?: boolean } = {} ): Promise { interruptActiveLoads(); @@ -1783,11 +1787,18 @@ export function createRouter(init: RouterInit): Router { }), }; } else { - let results = await callDataStrategy( - "action", + let dsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, request, - [actionMatch], matches, + actionMatch, + initialHydration ? [] : hydrationRouteProperties, + scopedContext + ); + let results = await callDataStrategy( + request, + dsMatches, scopedContext, null ); @@ -1947,12 +1958,17 @@ export function createRouter(init: RouterInit): Router { } let routesToUse = inFlightDataRoutes || dataRoutes; - let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( + let { dsMatches, revalidatingFetchers } = getMatchesToLoad( + request, + scopedContext, + mapRouteProperties, + manifest, init.history, state, matches, activeSubmission, location, + initialHydration ? [] : hydrationRouteProperties, initialHydration === true, isRevalidationRequired, cancelledFetcherLoads, @@ -1966,8 +1982,13 @@ export function createRouter(init: RouterInit): Router { pendingNavigationLoadId = ++incrementingLoadId; - // Short circuit if we have no loaders to run - if (matchesToLoad.length === 0 && revalidatingFetchers.length === 0) { + // Short circuit if we have no loaders to run, unless there's a custom dataStrategy + // since they may have different revalidation rules (i.e., single fetch) + if ( + !init.dataStrategy && + !dsMatches.some((m) => m.shouldLoad) && + revalidatingFetchers.length === 0 + ) { let updatedFetchers = markFetchRedirectsDone(); completeNavigation( location, @@ -2025,12 +2046,10 @@ export function createRouter(init: RouterInit): Router { let { loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( - matches, - matchesToLoad, + dsMatches, revalidatingFetchers, request, - scopedContext, - initialHydration + scopedContext ); if (request.signal.aborted) { @@ -2302,11 +2321,18 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; - let actionResults = await callDataStrategy( - "action", + let fetchMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, fetchRequest, - [match], requestMatches, + match, + hydrationRouteProperties, + scopedContext + ); + let actionResults = await callDataStrategy( + fetchRequest, + fetchMatches, scopedContext, key ); @@ -2378,12 +2404,17 @@ export function createRouter(init: RouterInit): Router { let loadFetcher = getLoadingFetcher(submission, actionResult.data); state.fetchers.set(key, loadFetcher); - let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( + let { dsMatches, revalidatingFetchers } = getMatchesToLoad( + revalidationRequest, + scopedContext, + mapRouteProperties, + manifest, init.history, state, matches, submission, nextLocation, + hydrationRouteProperties, false, isRevalidationRequired, cancelledFetcherLoads, @@ -2426,8 +2457,7 @@ export function createRouter(init: RouterInit): Router { let { loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( - matches, - matchesToLoad, + dsMatches, revalidatingFetchers, revalidationRequest, scopedContext @@ -2584,11 +2614,18 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; - let results = await callDataStrategy( - "loader", + let dsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, fetchRequest, - [match], matches, + match, + hydrationRouteProperties, + scopedContext + ); + let results = await callDataStrategy( + fetchRequest, + dsMatches, scopedContext, key ); @@ -2777,41 +2814,32 @@ export function createRouter(init: RouterInit): Router { // Utility wrapper for calling dataStrategy client-side without having to // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( - type: "loader" | "action", request: Request, - matchesToLoad: AgnosticDataRouteMatch[], - matches: AgnosticDataRouteMatch[], + matches: DataStrategyMatch[], scopedContext: unstable_RouterContextProvider, - fetcherKey: string | null, - initialHydration?: boolean + fetcherKey: string | null ): Promise> { let results: Record; let dataResults: Record = {}; - let lazyRoutePropertiesToSkip = initialHydration - ? [] - : hydrationRouteProperties; try { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, - type, request, - matchesToLoad, matches, fetcherKey, - manifest, - mapRouteProperties, - scopedContext, - lazyRoutePropertiesToSkip + scopedContext ); } catch (e) { // If the outer dataStrategy method throws, just return the error for all // matches - and it'll naturally bubble to the root - matchesToLoad.forEach((m) => { - dataResults[m.route.id] = { - type: ResultType.error, - error: e, - }; - }); + matches + .filter((m) => m.shouldLoad) + .forEach((m) => { + dataResults[m.route.id] = { + type: ResultType.error, + error: e, + }; + }); return dataResults; } @@ -2839,35 +2867,27 @@ export function createRouter(init: RouterInit): Router { } async function callLoadersAndMaybeResolveData( - matches: AgnosticDataRouteMatch[], - matchesToLoad: AgnosticDataRouteMatch[], + matches: DataStrategyMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request, - scopedContext: unstable_RouterContextProvider, - initialHydration?: boolean + scopedContext: unstable_RouterContextProvider ) { // Kick off loaders and fetchers in parallel let loaderResultsPromise = callDataStrategy( - "loader", request, - matchesToLoad, matches, scopedContext, - null, - initialHydration + null ); let fetcherResultsPromise = Promise.all( fetchersToLoad.map(async (f) => { - if (f.matches && f.match && f.controller) { + if (f.matches && f.match && f.request && f.controller) { let results = await callDataStrategy( - "loader", - createClientSideRequest(init.history, f.path, f.controller.signal), - [f.match], + f.request, f.matches, scopedContext, - f.key, - initialHydration + f.key ); let result = results[f.match.route.id]; // Fetcher results are keyed by fetcher key from here on out, not routeId @@ -3903,11 +3923,19 @@ export function createStaticHandler( error, }; } else { - let results = await callDataStrategy( - "action", + let dsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, request, - [actionMatch], matches, + actionMatch, + [], + requestContext + ); + + let results = await callDataStrategy( + request, + dsMatches, isRouteRequest, requestContext, dataStrategy @@ -4088,19 +4116,53 @@ export function createStaticHandler( }); } - let requestMatches = routeMatch - ? [routeMatch] - : pendingActionResult && isErrorResult(pendingActionResult[1]) - ? getLoaderMatchesUntilBoundary(matches, pendingActionResult[0]) - : matches; - let matchesToLoad = requestMatches.filter( - (m) => - (m.route.loader || m.route.lazy) && - (!filterMatchesToLoad || filterMatchesToLoad(m)) - ); + let dsMatches: DataStrategyMatch[]; + if (routeMatch) { + dsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, + request, + matches, + routeMatch, + [], + requestContext + ); + } else { + let maxIdx = + pendingActionResult && isErrorResult(pendingActionResult[1]) + ? // Up to but not including the boundary + matches.findIndex((m) => m.route.id === pendingActionResult[0]) - 1 + : undefined; + + dsMatches = matches.map((match, index) => { + if (maxIdx != null && index > maxIdx) { + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + [], + requestContext, + false + ); + } + + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + [], + requestContext, + (match.route.loader || match.route.lazy) != null && + (!filterMatchesToLoad || filterMatchesToLoad(match)) + ); + }); + } - // Short circuit if we have no loaders to run (query()) - if (matchesToLoad.length === 0) { + // Short circuit if we have no loaders to run, unless there's a custom dataStrategy + // since they may have different revalidation rules (i.e., single fetch) + if (!dataStrategy && !dsMatches.some((m) => m.shouldLoad)) { return { matches, // Add a null for all matched routes for proper revalidation on the client @@ -4120,10 +4182,8 @@ export function createStaticHandler( } let results = await callDataStrategy( - "loader", request, - matchesToLoad, - matches, + dsMatches, isRouteRequest, requestContext, dataStrategy @@ -4144,7 +4204,7 @@ export function createStaticHandler( // Add a null for any non-loader matches for proper revalidation on the client let executedLoaders = new Set( - matchesToLoad.map((match) => match.route.id) + dsMatches.filter((m) => m.shouldLoad).map((match) => match.route.id) ); matches.forEach((match) => { if (!executedLoaders.has(match.route.id)) { @@ -4161,23 +4221,17 @@ export function createStaticHandler( // Utility wrapper for calling dataStrategy server-side without having to // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( - type: "loader" | "action", request: Request, - matchesToLoad: AgnosticDataRouteMatch[], - matches: AgnosticDataRouteMatch[], + matches: DataStrategyMatch[], isRouteRequest: boolean, requestContext: unknown, dataStrategy: DataStrategyFunction | null ): Promise> { let results = await callDataStrategyImpl( dataStrategy || defaultDataStrategy, - type, request, - matchesToLoad, matches, null, - manifest, - mapRouteProperties, requestContext ); @@ -4491,26 +4545,17 @@ function normalizeNavigateOptions( return { path: createPath(parsedPath), submission }; } -// Filter out all routes at/below any caught error as they aren't going to -// render so we don't need to load them -function getLoaderMatchesUntilBoundary( - matches: AgnosticDataRouteMatch[], - boundaryId: string, - includeBoundary = false -) { - let index = matches.findIndex((m) => m.route.id === boundaryId); - if (index >= 0) { - return matches.slice(0, includeBoundary ? index + 1 : index); - } - return matches; -} - function getMatchesToLoad( + request: Request, + scopedContext: unknown, + mapRouteProperties: MapRoutePropertiesFunction, + manifest: RouteManifest, history: History, state: RouterState, matches: AgnosticDataRouteMatch[], submission: Submission | undefined, location: Location, + lazyRoutePropertiesToSkip: string[], initialHydration: boolean, isRevalidationRequired: boolean, cancelledFetcherLoads: Set, @@ -4520,7 +4565,10 @@ function getMatchesToLoad( routesToUse: AgnosticDataRouteObject[], basename: string | undefined, pendingActionResult?: PendingActionResult -): [AgnosticDataRouteMatch[], RevalidatingFetcher[]] { +): { + dsMatches: DataStrategyMatch[]; + revalidatingFetchers: RevalidatingFetcher[]; +} { let actionResult = pendingActionResult ? isErrorResult(pendingActionResult[1]) ? pendingActionResult[1].error @@ -4530,25 +4578,20 @@ function getMatchesToLoad( let nextUrl = history.createURL(location); // Pick navigation matches that are net-new or qualify for revalidation - let boundaryMatches = matches; + let maxIdx: number | undefined; if (initialHydration && state.errors) { // On initial hydration, only consider matches up to _and including_ the boundary. // This is inclusive to handle cases where a server loader ran successfully, // a child server loader bubbled up to this route, but this route has // `clientLoader.hydrate` so we want to still run the `clientLoader` so that // we have a complete version of `loaderData` - boundaryMatches = getLoaderMatchesUntilBoundary( - matches, - Object.keys(state.errors)[0], - true - ); + let boundaryId = Object.keys(state.errors)[0]; + maxIdx = matches.findIndex((m) => m.route.id === boundaryId); } else if (pendingActionResult && isErrorResult(pendingActionResult[1])) { // If an action threw an error, we call loaders up to, but not including the // boundary - boundaryMatches = getLoaderMatchesUntilBoundary( - matches, - pendingActionResult[0] - ); + let boundaryId = pendingActionResult[0]; + maxIdx = matches.findIndex((m) => m.route.id === boundaryId) - 1; } // Don't revalidate loaders by default after action 4xx/5xx responses @@ -4559,51 +4602,84 @@ function getMatchesToLoad( : undefined; let shouldSkipRevalidation = actionStatus && actionStatus >= 400; - let navigationMatches = boundaryMatches.filter((match, index) => { + let baseShouldRevalidateArgs = { + currentUrl, + currentParams: state.matches[0]?.params || {}, + nextUrl, + nextParams: matches[0].params, + ...submission, + actionResult, + actionStatus, + }; + + let dsMatches: DataStrategyMatch[] = matches.map((match, index) => { let { route } = match; - if (route.lazy) { - // We haven't loaded this route yet so we don't know if it's got a loader! - return true; - } - if (route.loader == null) { - return false; - } + // For these cases we don't let the user have control via shouldRevalidate + // and we either force the loader to run or not run + let forceShouldLoad: boolean | null = null; - if (initialHydration) { - return shouldLoadRouteOnHydration(route, state.loaderData, state.errors); + if (maxIdx != null && index > maxIdx) { + // Don't call loaders below the boundary + forceShouldLoad = false; + } else if (route.lazy) { + // We haven't loaded this route yet so we don't know if it's got a loader! + forceShouldLoad = true; + } else if (route.loader == null) { + // Nothing to load! + forceShouldLoad = false; + } else if (initialHydration) { + // Only run on hydration if this is a hydrating `clientLoader` + forceShouldLoad = shouldLoadRouteOnHydration( + route, + state.loaderData, + state.errors + ); + } else if (isNewLoader(state.loaderData, state.matches[index], match)) { + // Always call the loader on new route instances + forceShouldLoad = true; } - // Always call the loader on new route instances - if (isNewLoader(state.loaderData, state.matches[index], match)) { - return true; + if (forceShouldLoad !== null) { + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip, + scopedContext, + forceShouldLoad + ); } // This is the default implementation for when we revalidate. If the route // provides it's own implementation, then we give them full control but // provide this value so they can leverage it if needed after they check // their own specific use cases - let currentRouteMatch = state.matches[index]; - let nextRouteMatch = match; - - return shouldRevalidateLoader(match, { - currentUrl, - currentParams: currentRouteMatch.params, - nextUrl, - nextParams: nextRouteMatch.params, - ...submission, - actionResult, - actionStatus, - defaultShouldRevalidate: shouldSkipRevalidation - ? false - : // Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate - isRevalidationRequired || - currentUrl.pathname + currentUrl.search === - nextUrl.pathname + nextUrl.search || - // Search params affect all loaders - currentUrl.search !== nextUrl.search || - isNewRouteInstance(currentRouteMatch, nextRouteMatch), - }); + let defaultShouldRevalidate = shouldSkipRevalidation + ? false + : // Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate + isRevalidationRequired || + currentUrl.pathname + currentUrl.search === + nextUrl.pathname + nextUrl.search || + // Search params affect all loaders + currentUrl.search !== nextUrl.search || + isNewRouteInstance(state.matches[index], match); + let shouldRevalidateArgs = { + ...baseShouldRevalidateArgs, + defaultShouldRevalidate, + }; + let shouldLoad = shouldRevalidateLoader(match, shouldRevalidateArgs); + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip, + scopedContext, + shouldLoad, + shouldRevalidateArgs + ); }); // Pick fetcher.loads that need to be revalidated @@ -4635,64 +4711,100 @@ function getMatchesToLoad( path: f.path, matches: null, match: null, + request: null, controller: null, }); return; } + if (fetchRedirectIds.has(key)) { + // Never trigger a revalidation of an actively redirecting fetcher + return; + } + // Revalidating fetchers are decoupled from the route matches since they // load from a static href. They revalidate based on explicit revalidation // (submission, useRevalidator, or X-Remix-Revalidate) let fetcher = state.fetchers.get(key); let fetcherMatch = getTargetMatch(fetcherMatches, f.path); - let shouldRevalidate = false; - if (fetchRedirectIds.has(key)) { - // Never trigger a revalidation of an actively redirecting fetcher - shouldRevalidate = false; - } else if (cancelledFetcherLoads.has(key)) { + let fetchController = new AbortController(); + let fetchRequest = createClientSideRequest( + history, + f.path, + fetchController.signal + ); + + let fetcherDsMatches: DataStrategyMatch[] | null = null; + + if (cancelledFetcherLoads.has(key)) { // Always mark for revalidation if the fetcher was cancelled cancelledFetcherLoads.delete(key); - shouldRevalidate = true; + fetcherDsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, + fetchRequest, + fetcherMatches, + fetcherMatch, + lazyRoutePropertiesToSkip, + scopedContext + ); } else if ( fetcher && fetcher.state !== "idle" && fetcher.data === undefined ) { - // If the fetcher hasn't ever completed loading yet, then this isn't a - // revalidation, it would just be a brand new load if an explicit - // revalidation is required - shouldRevalidate = isRevalidationRequired; + if (isRevalidationRequired) { + // If the fetcher hasn't ever completed loading yet, then this isn't a + // revalidation, it would just be a brand new load if an explicit + // revalidation is required + fetcherDsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, + fetchRequest, + fetcherMatches, + fetcherMatch, + lazyRoutePropertiesToSkip, + scopedContext + ); + } } else { // Otherwise fall back on any user-defined shouldRevalidate, defaulting // to explicit revalidations only - shouldRevalidate = shouldRevalidateLoader(fetcherMatch, { - currentUrl, - currentParams: state.matches[state.matches.length - 1].params, - nextUrl, - nextParams: matches[matches.length - 1].params, - ...submission, - actionResult, - actionStatus, + let shouldRevalidateArgs: ShouldRevalidateFunctionArgs = { + ...baseShouldRevalidateArgs, defaultShouldRevalidate: shouldSkipRevalidation ? false : isRevalidationRequired, - }); + }; + if (shouldRevalidateLoader(fetcherMatch, shouldRevalidateArgs)) { + fetcherDsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, + fetchRequest, + fetcherMatches, + fetcherMatch, + lazyRoutePropertiesToSkip, + scopedContext, + shouldRevalidateArgs + ); + } } - if (shouldRevalidate) { + if (fetcherDsMatches) { revalidatingFetchers.push({ key, routeId: f.routeId, path: f.path, - matches: fetcherMatches, + matches: fetcherDsMatches, match: fetcherMatch, - controller: new AbortController(), + request: fetchRequest, + controller: fetchController, }); } }); - return [navigationMatches, revalidatingFetchers]; + return { dsMatches, revalidatingFetchers }; } function shouldLoadRouteOnHydration( @@ -5055,6 +5167,9 @@ function loadLazyRoute( lazyRouteFunctionCache.set(routeToUpdate, lazyRoutePromise); + // Prevent unhandled rejection errors - handled inside of `callLoadOrAction` + lazyRoutePromise.catch(() => {}); + return { lazyRoutePromise, lazyHandlerPromise: lazyRoutePromise, @@ -5091,6 +5206,10 @@ function loadLazyRoute( .then(() => {}) : undefined; + // Prevent unhandled rejection errors - handled inside of `callLoadOrAction` + lazyRoutePromise?.catch(() => {}); + lazyHandlerPromise?.catch(() => {}); + return { lazyRoutePromise, lazyHandlerPromise, @@ -5300,80 +5419,159 @@ async function callRouteMiddleware( } } -async function callDataStrategyImpl( - dataStrategyImpl: DataStrategyFunction, - type: "loader" | "action", - request: Request, - matchesToLoad: AgnosticDataRouteMatch[], - matches: AgnosticDataRouteMatch[], - fetcherKey: string | null, +function getDataStrategyMatchLazyPromises( + mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, + request: Request, + match: DataRouteMatch, + lazyRoutePropertiesToSkip: string[] +): DataStrategyMatch["_lazyPromises"] { + let lazyMiddlewarePromise = loadLazyRouteProperty({ + key: "unstable_middleware", + route: match.route, + manifest, + mapRouteProperties, + }); + + let lazyRoutePromises = loadLazyRoute( + match.route, + isMutationMethod(request.method) ? "action" : "loader", + manifest, + mapRouteProperties, + lazyRoutePropertiesToSkip + ); + + return { + middleware: lazyMiddlewarePromise, + route: lazyRoutePromises.lazyRoutePromise, + handler: lazyRoutePromises.lazyHandlerPromise, + }; +} + +function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, + manifest: RouteManifest, + request: Request, + match: DataRouteMatch, + lazyRoutePropertiesToSkip: string[], scopedContext: unknown, - lazyRoutePropertiesToSkip?: string[] -): Promise> { - // Ensure all lazy/lazyMiddleware async functions are kicked off in parallel - // before we await them where needed below - let loadMiddlewarePromise = loadLazyMiddlewareForMatches( - matches, + shouldLoad: boolean, + unstable_shouldRevalidateArgs: DataStrategyMatch["unstable_shouldRevalidateArgs"] = null +): DataStrategyMatch { + // The hope here is to avoid a breaking change to the resolve behavior. + // Opt-ing into the `unstable_shouldCallHandler` API changes some nuanced behavior + // around when resolve calls through to the handler + let isUsingNewApi = false; + + let _lazyPromises = getDataStrategyMatchLazyPromises( + mapRouteProperties, manifest, - mapRouteProperties - ); - let lazyRoutePromises = matches.map((m) => - loadLazyRoute( - m.route, - type, - manifest, - mapRouteProperties, - lazyRoutePropertiesToSkip - ) + request, + match, + lazyRoutePropertiesToSkip ); - // Ensure all middleware is loaded before we start executing routes - if (loadMiddlewarePromise) { - await loadMiddlewarePromise; - } + return { + ...match, + _lazyPromises, + shouldLoad, + unstable_shouldRevalidateArgs, + unstable_shouldCallHandler(defaultShouldRevalidate) { + isUsingNewApi = true; + if (!unstable_shouldRevalidateArgs) { + return shouldLoad; + } - let dsMatches = matches.map((match, i) => { - let { lazyRoutePromise, lazyHandlerPromise } = lazyRoutePromises[i]; - let shouldLoad = matchesToLoad.some((m) => m.route.id === match.route.id); - // `resolve` encapsulates route.lazy(), executing the loader/action, - // and mapping return values/thrown errors to a `DataStrategyResult`. Users - // can pass a callback to take fine-grained control over the execution - // of the loader/action - let resolve: DataStrategyMatch["resolve"] = async (handlerOverride) => { + if (typeof defaultShouldRevalidate === "boolean") { + return shouldRevalidateLoader(match, { + ...unstable_shouldRevalidateArgs, + defaultShouldRevalidate, + }); + } + return shouldRevalidateLoader(match, unstable_shouldRevalidateArgs); + }, + resolve(handlerOverride) { if ( - handlerOverride && - request.method === "GET" && - (match.route.lazy || match.route.loader) + isUsingNewApi || + shouldLoad || + (handlerOverride && + request.method === "GET" && + (match.route.lazy || match.route.loader)) ) { - shouldLoad = true; + return callLoaderOrAction({ + request, + match, + lazyHandlerPromise: _lazyPromises?.handler, + lazyRoutePromise: _lazyPromises?.route, + handlerOverride, + scopedContext, + }); } - return shouldLoad - ? callLoaderOrAction({ - type, - request, - match, - lazyHandlerPromise, - lazyRoutePromise, - handlerOverride, - scopedContext, - }) - : Promise.resolve({ type: ResultType.data, result: undefined }); - }; + return Promise.resolve({ type: ResultType.data, result: undefined }); + }, + }; +} - return { - ...match, - shouldLoad, - resolve, - }; +function getTargetedDataStrategyMatches( + mapRouteProperties: MapRoutePropertiesFunction, + manifest: RouteManifest, + request: Request, + matches: AgnosticDataRouteMatch[], + targetMatch: AgnosticDataRouteMatch, + lazyRoutePropertiesToSkip: string[], + scopedContext: unknown, + shouldRevalidateArgs: DataStrategyMatch["unstable_shouldRevalidateArgs"] = null +): DataStrategyMatch[] { + return matches.map((match) => { + if (match.route.id !== targetMatch.route.id) { + // We don't use getDataStrategyMatch here because these are for actions/fetchers + // where we should _never_ call the handler for any matches other than the target + return { + ...match, + shouldLoad: false, + unstable_shouldRevalidateArgs: shouldRevalidateArgs, + unstable_shouldCallHandler: () => false, + _lazyPromises: getDataStrategyMatchLazyPromises( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip + ), + resolve: () => Promise.resolve({ type: "data", result: undefined }), + }; + } + + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip, + scopedContext, + true, + shouldRevalidateArgs + ); }); +} + +async function callDataStrategyImpl( + dataStrategyImpl: DataStrategyFunction, + request: Request, + matches: DataStrategyMatch[], + fetcherKey: string | null, + scopedContext: unknown +): Promise> { + // Ensure all middleware is loaded before we start executing routes + if (matches.some((m) => m._lazyPromises?.middleware)) { + await Promise.all(matches.map((m) => m._lazyPromises?.middleware)); + } // Send all matches here to allow for a middleware-type implementation. // handler will be a no-op for unneeded routes and we filter those results // back out below. let results = await dataStrategyImpl({ - matches: dsMatches, + matches, request, params: matches[0].params, fetcherKey, @@ -5384,11 +5582,10 @@ async function callDataStrategyImpl( // it to bubble up from the `await loadRoutePromise` in `callLoaderOrAction` - // called from `match.resolve()`. We also ensure that all promises are // awaited so that we don't inadvertently leave any hanging promises. - let allLazyRoutePromises: Promise[] = lazyRoutePromises.flatMap( - (promiseMap) => Object.values(promiseMap).filter(isNonNullable) - ); try { - await Promise.all(allLazyRoutePromises); + await Promise.all( + matches.flatMap((m) => [m._lazyPromises?.handler, m._lazyPromises?.route]) + ); } catch (e) { // No-op } @@ -5398,7 +5595,6 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ - type, request, match, lazyHandlerPromise, @@ -5406,7 +5602,6 @@ async function callLoaderOrAction({ handlerOverride, scopedContext, }: { - type: "loader" | "action"; request: Request; match: AgnosticDataRouteMatch; lazyHandlerPromise: Promise | undefined; @@ -5416,7 +5611,8 @@ async function callLoaderOrAction({ }): Promise { let result: DataStrategyResult; let onReject: (() => void) | undefined; - + let isAction = isMutationMethod(request.method); + let type = isAction ? "action" : "loader"; let runHandler = ( handler: boolean | LoaderFunction | ActionFunction ): Promise => { @@ -5462,9 +5658,7 @@ async function callLoaderOrAction({ }; try { - let handler = match.route[type] as - | LoaderFunction - | ActionFunction; + let handler = isAction ? match.route.action : match.route.loader; // If we have a promise for a lazy route, await that first if (lazyHandlerPromise || lazyRoutePromise) { @@ -5490,9 +5684,7 @@ async function callLoaderOrAction({ // Load lazy loader/action before running it await lazyHandlerPromise; - handler = match.route[type] as - | LoaderFunction - | ActionFunction; + let handler = isAction ? match.route.action : match.route.loader; if (handler) { // Handler still runs even if we got interrupted to maintain consistency // with un-abortable behavior of handler execution on non-lazy or @@ -5859,33 +6051,37 @@ function processLoaderData( ); // Process results from our revalidating fetchers - revalidatingFetchers.forEach((rf) => { - let { key, match, controller } = rf; - let result = fetcherResults[key]; - invariant(result, "Did not find corresponding fetcher result"); - - // Process fetcher non-redirect errors - if (controller && controller.signal.aborted) { - // Nothing to do for aborted fetchers - return; - } else if (isErrorResult(result)) { - let boundaryMatch = findNearestBoundary(state.matches, match?.route.id); - if (!(errors && errors[boundaryMatch.route.id])) { - errors = { - ...errors, - [boundaryMatch.route.id]: result.error, - }; + revalidatingFetchers + // Keep those with no matches so we can bubble their 404's, otherwise only + // process fetchers that needed to load + .filter((f) => !f.matches || f.matches.some((m) => m.shouldLoad)) + .forEach((rf) => { + let { key, match, controller } = rf; + let result = fetcherResults[key]; + invariant(result, "Did not find corresponding fetcher result"); + + // Process fetcher non-redirect errors + if (controller && controller.signal.aborted) { + // Nothing to do for aborted fetchers + return; + } else if (isErrorResult(result)) { + let boundaryMatch = findNearestBoundary(state.matches, match?.route.id); + if (!(errors && errors[boundaryMatch.route.id])) { + errors = { + ...errors, + [boundaryMatch.route.id]: result.error, + }; + } + state.fetchers.delete(key); + } else if (isRedirectResult(result)) { + // Should never get here, redirects should get processed above, but we + // keep this to type narrow to a success result in the else + invariant(false, "Unhandled fetcher revalidation redirect"); + } else { + let doneFetcher = getDoneFetcher(result.data); + state.fetchers.set(key, doneFetcher); } - state.fetchers.delete(key); - } else if (isRedirectResult(result)) { - // Should never get here, redirects should get processed above, but we - // keep this to type narrow to a success result in the else - invariant(false, "Unhandled fetcher revalidation redirect"); - } else { - let doneFetcher = getDoneFetcher(result.data); - state.fetchers.set(key, doneFetcher); - } - }); + }); return { loaderData, errors }; } diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index b20d2323f5..7684368d32 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -331,7 +331,22 @@ export interface ShouldRevalidateFunction { export interface DataStrategyMatch extends AgnosticRouteMatch { + /** + * @private + */ + _lazyPromises?: { + middleware: Promise | undefined; + handler: Promise | undefined; + route: Promise | undefined; + }; shouldLoad: boolean; + // This can be null for actions calls and for initial hydration calls + unstable_shouldRevalidateArgs: ShouldRevalidateFunctionArgs | null; + // TODO: Figure out a good name for this or use `shouldLoad` and add a future flag + // This function will use a scoped version of `shouldRevalidateArgs` because + // they are read-only but let the user provide an optional override value for + // `defaultShouldRevalidate` if they choose + unstable_shouldCallHandler(defaultShouldRevalidate?: boolean): boolean; resolve: ( handlerOverride?: ( handler: (ctx?: unknown) => DataFunctionReturnValue @@ -342,6 +357,8 @@ export interface DataStrategyMatch export interface DataStrategyFunctionArgs extends DataFunctionArgs { matches: DataStrategyMatch[]; + // TODO: Implement + // runMiddleware: () => unknown, fetcherKey: string | null; } From c40f7861c44a7dbcf47a296a18fb642c2526fe6d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 8 Apr 2025 17:05:46 -0400 Subject: [PATCH 07/25] Update LoaderFunctionArgs and friends when middleware is enabled (#13381) --- .changeset/violet-carrots-work.md | 5 +++++ packages/react-router/lib/router/utils.ts | 17 +++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 .changeset/violet-carrots-work.md diff --git a/.changeset/violet-carrots-work.md b/.changeset/violet-carrots-work.md new file mode 100644 index 0000000000..127454578d --- /dev/null +++ b/.changeset/violet-carrots-work.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +UNSTABLE: Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 7684368d32..8de2c979d5 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1,3 +1,4 @@ +import type { MiddlewareEnabled } from "../types/future"; import type { Equal, Expect } from "../types/utils"; import type { Location, Path, To } from "./history"; import { invariant, parsePath, warning } from "./history"; @@ -172,6 +173,10 @@ export class unstable_RouterContextProvider { } } +type DefaultContext = MiddlewareEnabled extends true + ? unstable_RouterContextProvider + : any; + /** * @private * Arguments passed to route loader/action functions. Same for now but we keep @@ -225,13 +230,13 @@ export type unstable_MiddlewareFunction = ( /** * Arguments passed to loader functions */ -export interface LoaderFunctionArgs +export interface LoaderFunctionArgs extends DataFunctionArgs {} /** * Arguments passed to action functions */ -export interface ActionFunctionArgs +export interface ActionFunctionArgs extends DataFunctionArgs {} /** @@ -244,7 +249,7 @@ type DataFunctionReturnValue = MaybePromise; /** * Route loader function signature */ -export type LoaderFunction = { +export type LoaderFunction = { ( args: LoaderFunctionArgs, handlerCtx?: unknown @@ -254,7 +259,7 @@ export type LoaderFunction = { /** * Route action function signature */ -export interface ActionFunction { +export interface ActionFunction { ( args: ActionFunctionArgs, handlerCtx?: unknown @@ -354,7 +359,7 @@ export interface DataStrategyMatch ) => Promise; } -export interface DataStrategyFunctionArgs +export interface DataStrategyFunctionArgs extends DataFunctionArgs { matches: DataStrategyMatch[]; // TODO: Implement @@ -370,7 +375,7 @@ export interface DataStrategyResult { result: unknown; // data, Error, Response, DeferredData, DataWithResponseInit } -export interface DataStrategyFunction { +export interface DataStrategyFunction { (args: DataStrategyFunctionArgs): Promise< Record >; From aae4b2cd6a29bcb56e7f33ebdaed5f130b4f591b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 9 Apr 2025 11:35:50 -0400 Subject: [PATCH 08/25] Stop inserting null placeholders for non-executed loaders in staticHandler.query (#13223) --- .changeset/smart-ads-doubt.md | 9 ++++++ .../__tests__/dom/data-static-router-test.tsx | 5 +-- .../__tests__/router/lazy-test.ts | 20 +++--------- .../react-router/__tests__/router/ssr-test.ts | 9 ++---- .../__tests__/server-runtime/server-test.ts | 32 +++---------------- packages/react-router/lib/router/router.ts | 16 +--------- 6 files changed, 22 insertions(+), 69 deletions(-) create mode 100644 .changeset/smart-ads-doubt.md diff --git a/.changeset/smart-ads-doubt.md b/.changeset/smart-ads-doubt.md new file mode 100644 index 0000000000..5ac7306418 --- /dev/null +++ b/.changeset/smart-ads-doubt.md @@ -0,0 +1,9 @@ +--- +"react-router": patch +--- + +Do not automatically add `null` to `staticHandler.query()` `context.loaderData` if routes do not have loaders + +- This was a Remix v2 implementation detail inadvertently left in for React Router v7 +- Now that we allow returning `undefined` from loaders, our prior check of `loaderData[routeId] !== undefined` was no longer sufficient and was changed to a `routeId in loaderData` check - these `null` values can cause issues for this new check +- ⚠️ This could be a "breaking bug fix" for you if you are doing manual SSR with `createStaticHandler()`/``, and using `context.loaderData` to control `` hydration behavior on the client diff --git a/packages/react-router/__tests__/dom/data-static-router-test.tsx b/packages/react-router/__tests__/dom/data-static-router-test.tsx index 9a738d1a90..e7a7a2f6cb 100644 --- a/packages/react-router/__tests__/dom/data-static-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-static-router-test.tsx @@ -901,10 +901,7 @@ describe("A ", () => { let expectedJsonString = JSON.stringify( JSON.stringify({ - loaderData: { - 0: null, - "0-0": null, - }, + loaderData: {}, actionData: null, errors: null, }) diff --git a/packages/react-router/__tests__/router/lazy-test.ts b/packages/react-router/__tests__/router/lazy-test.ts index a9aa77244f..cc6dcfa7ce 100644 --- a/packages/react-router/__tests__/router/lazy-test.ts +++ b/packages/react-router/__tests__/router/lazy-test.ts @@ -2930,9 +2930,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ lazy: new Error("LAZY LOADER ERROR"), }); @@ -2969,9 +2967,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ lazy: new Error("LAZY LOADER ERROR"), }); @@ -3006,9 +3002,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); @@ -3045,9 +3039,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); @@ -3084,9 +3076,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); diff --git a/packages/react-router/__tests__/router/ssr-test.ts b/packages/react-router/__tests__/router/ssr-test.ts index 4ef923a368..77faaaba05 100644 --- a/packages/react-router/__tests__/router/ssr-test.ts +++ b/packages/react-router/__tests__/router/ssr-test.ts @@ -186,7 +186,7 @@ describe("ssr", () => { }); }); - it("should fill in null loaderData values for routes without loaders", async () => { + it("should not fill in null loaderData values for routes without loaders", async () => { let { query } = createStaticHandler([ { id: "root", @@ -215,10 +215,7 @@ describe("ssr", () => { let context = await query(createRequest("/none")); expect(context).toMatchObject({ actionData: null, - loaderData: { - root: null, - none: null, - }, + loaderData: {}, errors: null, location: { pathname: "/none" }, }); @@ -228,9 +225,7 @@ describe("ssr", () => { expect(context).toMatchObject({ actionData: null, loaderData: { - root: null, a: "A", - b: null, }, errors: null, location: { pathname: "/a/b" }, diff --git a/packages/react-router/__tests__/server-runtime/server-test.ts b/packages/react-router/__tests__/server-runtime/server-test.ts index 2c4fae9755..fa62752bb8 100644 --- a/packages/react-router/__tests__/server-runtime/server-test.ts +++ b/packages/react-router/__tests__/server-runtime/server-test.ts @@ -1339,10 +1339,7 @@ describe("shared server runtime", () => { let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); expect(context.errors!.root.status).toBe(400); - expect(context.loaderData).toEqual({ - root: null, - "routes/test": null, - }); + expect(context.loaderData).toEqual({}); }); test("thrown action responses bubble up for index routes", async () => { @@ -1386,10 +1383,7 @@ describe("shared server runtime", () => { let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); expect(context.errors!.root.status).toBe(400); - expect(context.loaderData).toEqual({ - root: null, - "routes/_index": null, - }); + expect(context.loaderData).toEqual({}); }); test("thrown action responses catch deep", async () => { @@ -1435,7 +1429,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/test"].status).toBe(400); expect(context.loaderData).toEqual({ root: "root", - "routes/test": null, }); }); @@ -1482,7 +1475,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/_index"].status).toBe(400); expect(context.loaderData).toEqual({ root: "root", - "routes/_index": null, }); }); @@ -1537,8 +1529,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/__layout"].data).toBe("action"); expect(context.loaderData).toEqual({ root: "root", - "routes/__layout": null, - "routes/__layout/test": null, }); }); @@ -1593,8 +1583,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/__layout"].data).toBe("action"); expect(context.loaderData).toEqual({ root: "root", - "routes/__layout": null, - "routes/__layout/index": null, }); }); @@ -1728,10 +1716,7 @@ describe("shared server runtime", () => { expect(context.errors!.root).toBeInstanceOf(Error); expect(context.errors!.root.message).toBe("Unexpected Server Error"); expect(context.errors!.root.stack).toBeUndefined(); - expect(context.loaderData).toEqual({ - root: null, - "routes/test": null, - }); + expect(context.loaderData).toEqual({}); }); test("action errors bubble up for index routes", async () => { @@ -1777,10 +1762,7 @@ describe("shared server runtime", () => { expect(context.errors!.root).toBeInstanceOf(Error); expect(context.errors!.root.message).toBe("Unexpected Server Error"); expect(context.errors!.root.stack).toBeUndefined(); - expect(context.loaderData).toEqual({ - root: null, - "routes/_index": null, - }); + expect(context.loaderData).toEqual({}); }); test("action errors catch deep", async () => { @@ -1830,7 +1812,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/test"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", - "routes/test": null, }); }); @@ -1881,7 +1862,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/_index"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", - "routes/_index": null, }); }); @@ -1940,8 +1920,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/__layout"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", - "routes/__layout": null, - "routes/__layout/test": null, }); }); @@ -2000,8 +1978,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/__layout"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", - "routes/__layout": null, - "routes/__layout/index": null, }); }); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index f66e4263dd..b622f53f5c 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -4165,11 +4165,7 @@ export function createStaticHandler( if (!dataStrategy && !dsMatches.some((m) => m.shouldLoad)) { return { matches, - // Add a null for all matched routes for proper revalidation on the client - loaderData: matches.reduce( - (acc, m) => Object.assign(acc, { [m.route.id]: null }), - {} - ), + loaderData: {}, errors: pendingActionResult && isErrorResult(pendingActionResult[1]) ? { @@ -4202,16 +4198,6 @@ export function createStaticHandler( skipLoaderErrorBubbling ); - // Add a null for any non-loader matches for proper revalidation on the client - let executedLoaders = new Set( - dsMatches.filter((m) => m.shouldLoad).map((match) => match.route.id) - ); - matches.forEach((match) => { - if (!executedLoaders.has(match.route.id)) { - handlerContext.loaderData[match.route.id] = null; - } - }); - return { ...handlerContext, matches, From 52358da776a45c34a91383f3011d56abb402c5aa Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 9 Apr 2025 12:05:09 -0400 Subject: [PATCH 09/25] Refactor dataStrategy for easier RSC abstraction (#13344) --- .../react-router/lib/dom/ssr/single-fetch.tsx | 186 ++++++++++-------- .../react-router/lib/server-runtime/routes.ts | 1 - .../react-router/lib/server-runtime/server.ts | 11 +- .../lib/server-runtime/single-fetch.ts | 29 ++- 4 files changed, 120 insertions(+), 107 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index c2bb6d5319..03591c834c 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -18,9 +18,7 @@ import { import { createRequestInit } from "./data"; import type { AssetsManifest, EntryContext } from "./entry"; import { escapeHtml } from "./markup"; -import type { RouteModule, RouteModules } from "./routeModules"; import invariant from "./invariant"; -import type { EntryRoute } from "./routes"; export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); @@ -32,15 +30,22 @@ export type SingleFetchRedirectResult = { replace: boolean; }; +// Shared/serializable type used by both turbo-stream and RSC implementations +type DecodedSingleFetchResults = + | { routes: { [key: string]: SingleFetchResult } } + | { redirect: SingleFetchRedirectResult }; + +// This and SingleFetchResults are only used over the wire, and are converted to +// DecodedSingleFetchResults in `fetchAndDecode`. This way turbo-stream/RSC +// can use the same `unwrapSingleFetchResult` implementation. export type SingleFetchResult = | { data: unknown } | { error: unknown } | SingleFetchRedirectResult; -export type SingleFetchResults = { - [key: string]: SingleFetchResult; - [SingleFetchRedirectSymbol]?: SingleFetchRedirectResult; -}; +export type SingleFetchResults = + | { [key: string]: SingleFetchResult } + | { [SingleFetchRedirectSymbol]: SingleFetchRedirectResult }; interface StreamTransferProps { context: EntryContext; @@ -50,6 +55,16 @@ interface StreamTransferProps { nonce?: string; } +// Some status codes are not permitted to have bodies, so we want to just +// treat those as "no data" instead of throwing an exception: +// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx +// https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content +// https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content +// +// Note: 304 is not included here because the browser should fill those responses +// with the cached body content. +export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]); + // StreamTransfer recursively renders down chunks of the `serverHandoffStream` // into the client-side `streamController` export function StreamTransfer({ @@ -245,12 +260,13 @@ async function singleFetchActionStrategy( let result = await handler(async () => { let url = singleFetchUrl(request.url, basename); let init = await createRequestInit(request); - let { data, status } = await fetchAndDecode(url, init); - actionStatus = status; - return unwrapSingleFetchResult( - data as SingleFetchResult, + let { data, status } = await fetchAndDecode( + url, + init, actionMatch!.route.id ); + actionStatus = status; + return unwrapSingleFetchResult(data, actionMatch!.route.id); }); return result; }); @@ -308,23 +324,17 @@ async function singleFetchLoaderNavigationStrategy( router: DataRouter, basename: string | undefined ) { - // Track which routes need a server load - in case we need to tack on a - // `_routes` param + // Track which routes need a server load for use in a `_routes` param let routesParams = new Set(); - // We only add `_routes` when one or more routes opts out of a load via - // `shouldRevalidate` or `clientLoader` + // Only add `_routes` when at least 1 route opts out via `shouldRevalidate`/`clientLoader` let foundOptOutRoute = false; - // Deferreds for each route so we can be sure they've all loaded via - // `match.resolve()`, and a singular promise that can tell us all routes - // have been resolved + // Deferreds per-route so we can be sure they've all loaded via `match.resolve()` let routeDfds = matches.map(() => createDeferred()); - let routesLoadedPromise = Promise.all(routeDfds.map((d) => d.promise)); - // Deferred that we'll use for the call to the server that each match can - // await and parse out it's specific result - let singleFetchDfd = createDeferred(); + // Deferred we'll use for the singleular call to the server + let singleFetchDfd = createDeferred(); // Base URL and RequestInit for calls to the server let url = stripIndexParam(singleFetchUrl(request.url, basename)); @@ -339,6 +349,7 @@ async function singleFetchLoaderNavigationStrategy( routeDfds[i].resolve(); let manifestRoute = manifest.routes[m.route.id]; + invariant(manifestRoute, "No manifest route found for dataStrategy"); let defaultShouldRevalidate = !m.unstable_shouldRevalidateArgs || @@ -347,8 +358,7 @@ async function singleFetchLoaderNavigationStrategy( let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate); if (!shouldCall) { - // If this route opted out of revalidation, we don't want to include - // it in the single fetch .data request + // If this route opted out, don't include in the .data request foundOptOutRoute ||= m.unstable_shouldRevalidateArgs != null && // This is a revalidation, manifestRoute?.hasLoader === true && // for a route with a server loader, @@ -358,7 +368,7 @@ async function singleFetchLoaderNavigationStrategy( // When a route has a client loader, it opts out of the singular call and // calls it's server loader via `serverLoader()` using a `?_routes` param - if (manifestRoute && manifestRoute.hasClientLoader) { + if (manifestRoute.hasClientLoader) { if (manifestRoute.hasLoader) { foundOptOutRoute = true; } @@ -385,7 +395,7 @@ async function singleFetchLoaderNavigationStrategy( try { let result = await handler(async () => { let data = await singleFetchDfd.promise; - return unwrapSingleFetchResults(data, m.route.id); + return unwrapSingleFetchResult(data, m.route.id); }); results[m.route.id] = { type: "data", @@ -402,7 +412,7 @@ async function singleFetchLoaderNavigationStrategy( ); // Wait for all routes to resolve above before we make the HTTP call - await routesLoadedPromise; + await Promise.all(routeDfds.map((d) => d.promise)); // We can skip the server call: // - On initial hydration - only clientLoaders can pass through via `clientLoader.hydrate` @@ -417,24 +427,18 @@ async function singleFetchLoaderNavigationStrategy( ) { singleFetchDfd.resolve({}); } else { - try { - // When one or more routes have opted out, we add a _routes param to - // limit the loaders to those that have a server loader and did not - // opt out - if (ssr && foundOptOutRoute && routesParams.size > 0) { - url.searchParams.set( - "_routes", - matches - .filter((m) => routesParams.has(m.route.id)) - .map((m) => m.route.id) - .join(",") - ); - } + // When routes have opted out, add a `_routes` param to filter server loaders + // Skipped in `ssr:false` because we expect to be loading static `.data` files + if (ssr && foundOptOutRoute && routesParams.size > 0) { + let routes = [...routesParams.keys()].join(","); + url.searchParams.set("_routes", routes); + } + try { let data = await fetchAndDecode(url, init); - singleFetchDfd.resolve(data.data as SingleFetchResults); + singleFetchDfd.resolve(data.data); } catch (e) { - singleFetchDfd.reject(e as Error); + singleFetchDfd.reject(e); } } @@ -471,7 +475,7 @@ function fetchSingleLoader( let singleLoaderUrl = new URL(url); singleLoaderUrl.searchParams.set("_routes", routeId); let { data } = await fetchAndDecode(singleLoaderUrl, init); - return unwrapSingleFetchResults(data as SingleFetchResults, routeId); + return unwrapSingleFetchResult(data, routeId); }); } @@ -520,8 +524,9 @@ export function singleFetchUrl( async function fetchAndDecode( url: URL, - init: RequestInit -): Promise<{ status: number; data: unknown }> { + init: RequestInit, + routeId?: string +): Promise<{ status: number; data: DecodedSingleFetchResults }> { let res = await fetch(url, init); // If this 404'd without hitting the running server (most likely in a @@ -530,27 +535,39 @@ async function fetchAndDecode( throw new ErrorResponseImpl(404, "Not Found", true); } - // some status codes are not permitted to have bodies, so we want to just - // treat those as "no data" instead of throwing an exception. - // 304 is not included here because the browser should fill those responses - // with the cached body content. - const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]); if (NO_BODY_STATUS_CODES.has(res.status)) { - if (!init.method || init.method === "GET") { - // SingleFetchResults can just have no routeId keys which will result - // in no data for all routes - return { status: res.status, data: {} }; - } else { - // SingleFetchResult is for a singular route and can specify no data - return { status: res.status, data: { data: undefined } }; + let routes: { [key: string]: SingleFetchResult } = {}; + if (routeId) { + routes[routeId] = { data: undefined }; } + return { + status: res.status, + data: { routes }, + }; } invariant(res.body, "No response body to decode"); try { let decoded = await decodeViaTurboStream(res.body, window); - return { status: res.status, data: decoded.value }; + let data: DecodedSingleFetchResults; + if (!init.method || init.method === "GET") { + let typed = decoded.value as SingleFetchResults; + if (SingleFetchRedirectSymbol in typed) { + data = { redirect: typed[SingleFetchRedirectSymbol] }; + } else { + data = { routes: typed }; + } + } else { + let typed = decoded.value as SingleFetchResult; + invariant(routeId, "No routeId found for single fetch call decoding"); + if ("redirect" in typed) { + data = { redirect: typed }; + } else { + data = { routes: { [routeId]: typed } }; + } + } + return { status: res.status, data }; } catch (e) { // Can't clone after consuming the body via turbo-stream so we can't // include the body here. In an ideal world we'd look for a turbo-stream @@ -617,37 +634,34 @@ export function decodeViaTurboStream( }); } -function unwrapSingleFetchResults( - results: SingleFetchResults, +function unwrapSingleFetchResult( + result: DecodedSingleFetchResults, routeId: string ) { - let redirect = results[SingleFetchRedirectSymbol]; - if (redirect) { - return unwrapSingleFetchResult(redirect, routeId); + if ("redirect" in result) { + let { + redirect: location, + revalidate, + reload, + replace, + status, + } = result.redirect; + throw redirect(location, { + status, + headers: { + // Three R's of redirecting (lol Veep) + ...(revalidate ? { "X-Remix-Revalidate": "yes" } : null), + ...(reload ? { "X-Remix-Reload-Document": "yes" } : null), + ...(replace ? { "X-Remix-Replace": "yes" } : null), + }, + }); } - return results[routeId] !== undefined - ? unwrapSingleFetchResult(results[routeId], routeId) - : null; -} - -function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { - if ("error" in result) { - throw result.error; - } else if ("redirect" in result) { - let headers: Record = {}; - if (result.revalidate) { - headers["X-Remix-Revalidate"] = "yes"; - } - if (result.reload) { - headers["X-Remix-Reload-Document"] = "yes"; - } - if (result.replace) { - headers["X-Remix-Replace"] = "yes"; - } - throw redirect(result.redirect, { status: result.status, headers }); - } else if ("data" in result) { - return result.data; + let routeResult = result.routes[routeId]; + if ("error" in routeResult) { + throw routeResult.error; + } else if ("data" in routeResult) { + return routeResult.data; } else { throw new Error(`No response found for routeId "${routeId}"`); } @@ -655,7 +669,7 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { function createDeferred() { let resolve: (val?: any) => Promise; - let reject: (error?: Error) => Promise; + let reject: (error?: unknown) => Promise; let promise = new Promise((res, rej) => { resolve = async (val: T) => { res(val); @@ -663,7 +677,7 @@ function createDeferred() { await promise; } catch (e) {} }; - reject = async (error?: Error) => { + reject = async (error?: unknown) => { rej(error); try { await promise; diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index e7bcf80978..ab22c9c2e5 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -2,7 +2,6 @@ import type { AgnosticDataRouteObject, LoaderFunctionArgs as RRLoaderFunctionArgs, ActionFunctionArgs as RRActionFunctionArgs, - RedirectFunction, RouteManifest, unstable_MiddlewareFunction, } from "../router/utils"; diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 84c0bac3c9..5cba00bcd4 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -24,18 +24,21 @@ import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createServerHandoffString } from "./serverHandoff"; import { getDevServerHooks } from "./dev"; -import type { SingleFetchResult, SingleFetchResults } from "./single-fetch"; import { encodeViaTurboStream, getSingleFetchRedirect, singleFetchAction, singleFetchLoaders, - SingleFetchRedirectSymbol, SINGLE_FETCH_REDIRECT_STATUS, - NO_BODY_STATUS_CODES, + SERVER_NO_BODY_STATUS_CODES, } from "./single-fetch"; import { getDocumentHeaders } from "./headers"; import type { EntryRoute } from "../dom/ssr/routes"; +import type { + SingleFetchResult, + SingleFetchResults, +} from "../dom/ssr/single-fetch"; +import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch"; import type { MiddlewareEnabled } from "../types/future"; export type RequestHandler = ( @@ -448,7 +451,7 @@ async function handleDocumentRequest( let headers = getDocumentHeaders(build, context); // Skip response body for unsupported status codes - if (NO_BODY_STATUS_CODES.has(context.statusCode)) { + if (SERVER_NO_BODY_STATUS_CODES.has(context.statusCode)) { return new Response(null, { status: context.statusCode, headers }); } diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 145b09f4bc..21c2d84538 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -18,27 +18,16 @@ import type { SingleFetchResult, SingleFetchResults, } from "../dom/ssr/single-fetch"; -import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch"; +import { + NO_BODY_STATUS_CODES, + SingleFetchRedirectSymbol, +} from "../dom/ssr/single-fetch"; import type { AppLoadContext } from "./data"; import { sanitizeError, sanitizeErrors } from "./errors"; import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; -export type { SingleFetchResult, SingleFetchResults }; -export { SingleFetchRedirectSymbol }; - -// Do not include a response body if the status code is one of these, -// otherwise `undici` will throw an error when constructing the Response: -// https://github.com/nodejs/undici/blob/bd98a6303e45d5e0d44192a93731b1defdb415f3/lib/web/fetch/response.js#L522-L528 -// -// Specs: -// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx -// https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content -// https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content -// https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified -export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205, 304]); - // We can't use a 3xx status or else the `fetch()` would follow the redirect. // We need to communicate the redirect back as data so we can act on it in the // client side router. We use a 202 to avoid any automatic caching we might @@ -46,6 +35,14 @@ export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205, 304]); // the user control cache behavior via Cache-Control export const SINGLE_FETCH_REDIRECT_STATUS = 202; +// Add 304 for server side - that is not included in the client side logic +// because the browser should fill those responses with the cached data +// https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified +export const SERVER_NO_BODY_STATUS_CODES = new Set([ + ...NO_BODY_STATUS_CODES, + 304, +]); + export async function singleFetchAction( build: ServerBuild, serverMode: ServerMode, @@ -280,7 +277,7 @@ function generateSingleFetchResponse( resultHeaders.set("X-Remix-Response", "yes"); // Skip response body for unsupported status codes - if (NO_BODY_STATUS_CODES.has(status)) { + if (SERVER_NO_BODY_STATUS_CODES.has(status)) { return new Response(null, { status, headers: resultHeaders }); } From 9b3accc5e3cab61b531607ed4044f30367f5be62 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 9 Apr 2025 12:22:43 -0400 Subject: [PATCH 10/25] fix lint issues --- .eslintignore | 1 + packages/react-router/lib/dom/ssr/routes.tsx | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintignore b/.eslintignore index fa3b682248..26b8a0ae29 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ node_modules/ pnpm-lock.yaml /docs/api examples/**/dist/ +/integration/helpers/vite-plugin-cloudflare-template/worker-configuration.d.ts /playground/ /playground-local/ packages/**/dist/ diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index b9ed35559e..985b3246ac 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -4,7 +4,6 @@ import type { HydrationState } from "../../router/router"; import type { ActionFunctionArgs, LoaderFunctionArgs, - unstable_MiddlewareFunction, RouteManifest, ShouldRevalidateFunction, ShouldRevalidateFunctionArgs, From 8bf71777a47c1225d31d6128d6bd4d821aa1536b Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 10 Apr 2025 17:32:14 +1000 Subject: [PATCH 11/25] Fix flaky split route modules test (#13394) --- integration/split-route-modules-test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/integration/split-route-modules-test.ts b/integration/split-route-modules-test.ts index 404673504c..cc35670c4a 100644 --- a/integration/split-route-modules-test.ts +++ b/integration/split-route-modules-test.ts @@ -56,7 +56,7 @@ const files = { } })(); const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Client loader wasn't unblocked after 2s")), 2000); + setTimeout(() => reject(new Error("Client loader wasn't unblocked after 5s")), 5000); }); await Promise.race([pollingPromise, timeoutPromise]); return { @@ -131,7 +131,7 @@ const files = { } })(); const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Client loader wasn't unblocked after 2s")), 2000); + setTimeout(() => reject(new Error("Client loader wasn't unblocked after 5s")), 5000); }); await Promise.race([pollingPromise, timeoutPromise]); return "clientLoader in main chunk: " + eval("typeof inUnsplittableMainChunk === 'function'"); @@ -419,8 +419,10 @@ test.describe("Split route modules", async () => { // Ensure splittable client loader works during SSR await page.goto(`http://localhost:${port}/splittable`); - expect(page.locator("[data-hydrate-fallback]")).toHaveText("Loading..."); - expect(page.locator("[data-hydrate-fallback]")).toHaveCSS( + await expect(page.locator("[data-hydrate-fallback]")).toHaveText( + "Loading..." + ); + await expect(page.locator("[data-hydrate-fallback]")).toHaveCSS( "padding", "20px" ); @@ -431,7 +433,9 @@ test.describe("Split route modules", async () => { // Ensure unsplittable client loader works during SSR await page.goto(`http://localhost:${port}/unsplittable`); - expect(page.locator("[data-hydrate-fallback]")).toHaveText("Loading..."); + await expect(page.locator("[data-hydrate-fallback]")).toHaveText( + "Loading..." + ); await unblockClientLoader(page); await expect(page.locator("[data-loader-data]")).toHaveText( `loaderData = "clientLoader in main chunk: true"` From 416d3e2971a52a75fefa96742544f29e08995e9e Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 10 Apr 2025 18:07:57 +1000 Subject: [PATCH 12/25] Remove missing react-router exports in tests (#13392) --- integration/action-test.ts | 3 +-- integration/blocking-test.ts | 2 +- integration/client-data-test.ts | 6 +----- integration/error-boundary-test.ts | 1 - integration/fetch-globals-test.ts | 1 - integration/fetcher-test.ts | 1 - integration/link-test.ts | 3 +-- integration/multiple-cookies-test.ts | 3 +-- integration/redirects-test.ts | 3 +-- integration/resource-routes-test.ts | 1 - integration/revalidate-test.ts | 6 ++---- integration/scroll-test.ts | 3 +-- integration/transition-test.ts | 6 ++---- 13 files changed, 11 insertions(+), 28 deletions(-) diff --git a/integration/action-test.ts b/integration/action-test.ts index 7bb077c18c..c08e6d4bdb 100644 --- a/integration/action-test.ts +++ b/integration/action-test.ts @@ -74,8 +74,7 @@ test.describe("actions", () => { `, [`app/routes/${THROWS_REDIRECT}.jsx`]: js` - import { redirect } from "react-router"; - import { Form } from "react-router"; + import { redirect, Form } from "react-router"; export function action() { throw redirect("/${REDIRECT_TARGET}") diff --git a/integration/blocking-test.ts b/integration/blocking-test.ts index 5b99df29b8..1929f02b48 100644 --- a/integration/blocking-test.ts +++ b/integration/blocking-test.ts @@ -40,7 +40,7 @@ test("handles synchronous proceeding correctly", async ({ page }) => { `, "app/routes/b.tsx": js` import * as React from "react"; - import { Form, useAction, useBlocker } from "react-router"; + import { Form, useBlocker } from "react-router"; export default function Component() { return (
diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 611ca5bf41..e0006135ae 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -335,7 +335,6 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { json } from "react-router" import { Await, useLoaderData } from "react-router" export function loader() { return { @@ -685,7 +684,6 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { json } from "react-router"; import { useLoaderData, useRevalidator } from "react-router"; let isFirstCall = true; export async function loader({ serverLoader }) { @@ -763,7 +761,7 @@ test.describe("Client Data", () => { childClientLoaderHydrate: false, }), "app/routes/parent.child.tsx": js` - import { ClientLoaderFunctionArgs, useRouteError } from "react-router"; + import { useRouteError } from "react-router"; export function loader() { throw new Error("Broken!") @@ -1286,7 +1284,6 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { json } from "react-router"; import { Form, useRouteError } from "react-router"; export async function clientAction({ serverAction }) { return await serverAction(); @@ -1508,7 +1505,6 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { json } from "react-router"; import { Form, useRouteError } from "react-router"; export async function clientAction({ serverAction }) { return await serverAction(); diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts index 0ec725d5fa..42f2312ddb 100644 --- a/integration/error-boundary-test.ts +++ b/integration/error-boundary-test.ts @@ -971,7 +971,6 @@ test("Allows back-button out of an error boundary after a hard reload", async ({ `, "app/routes/boom.tsx": js` - import { json } from "react-router"; export function loader() { return boom(); } export default function() { return my page; } `, diff --git a/integration/fetch-globals-test.ts b/integration/fetch-globals-test.ts index f7a5c3fe42..2d07e9ada6 100644 --- a/integration/fetch-globals-test.ts +++ b/integration/fetch-globals-test.ts @@ -14,7 +14,6 @@ test.beforeAll(async () => { fixture = await createFixture({ files: { "app/routes/_index.tsx": js` - import { json } from "react-router"; import { useLoaderData } from "react-router"; export async function loader() { const resp = await fetch('http://github.com/service/https://reqres.in/api/users?page=2'); diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index 138300cb25..36472a25cf 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -23,7 +23,6 @@ test.describe("useFetcher", () => { fixture = await createFixture({ files: { "app/routes/resource-route-action-only.ts": js` - import { json } from "react-router"; export function action() { return new Response("${CHEESESTEAK}"); } diff --git a/integration/link-test.ts b/integration/link-test.ts index 1c7ffb3c23..4d2d331f43 100644 --- a/integration/link-test.ts +++ b/integration/link-test.ts @@ -357,8 +357,7 @@ test.describe("route module link export", () => { `, "app/routes/gists.$username.tsx": js` - import { data, redirect } from "react-router"; - import { Link, useLoaderData, useParams } from "react-router"; + import { data, redirect, Link, useLoaderData, useParams } from "react-router"; export async function loader({ params }) { let { username } = params; if (username === "mjijackson") { diff --git a/integration/multiple-cookies-test.ts b/integration/multiple-cookies-test.ts index 96eabed064..e83b5640fb 100644 --- a/integration/multiple-cookies-test.ts +++ b/integration/multiple-cookies-test.ts @@ -16,8 +16,7 @@ test.describe("pathless layout routes", () => { await createFixture({ files: { "app/routes/_index.tsx": js` - import { data, redirect } from "react-router"; - import { Form, useActionData } from "react-router"; + import { data, redirect, Form, useActionData } from "react-router"; export let loader = async () => { let headers = new Headers(); diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts index b498f0a0d8..f3eefd7963 100644 --- a/integration/redirects-test.ts +++ b/integration/redirects-test.ts @@ -35,8 +35,7 @@ test.describe("redirects", () => { `, "app/routes/absolute._index.tsx": js` - import { redirect } from "react-router"; - import { Form } from "react-router"; + import { redirect, Form } from "react-router"; export async function action({ request }) { return redirect(new URL(request.url).origin + "/absolute/landing"); diff --git a/integration/resource-routes-test.ts b/integration/resource-routes-test.ts index 1e607c754f..0d02cef85f 100644 --- a/integration/resource-routes-test.ts +++ b/integration/resource-routes-test.ts @@ -114,7 +114,6 @@ test.describe("loader in an app", async () => { } `, "app/routes/$.tsx": js` - import { json } from "react-router"; import { useRouteError } from "react-router"; export function loader({ request }) { throw Response.json({ diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts index 96f7ef6228..b14c73ff5d 100644 --- a/integration/revalidate-test.ts +++ b/integration/revalidate-test.ts @@ -46,8 +46,7 @@ test.describe("Revalidation", () => { `, "app/routes/parent.tsx": js` - import { data } from "react-router"; - import { Outlet, useLoaderData } from "react-router"; + import { data, Outlet, useLoaderData } from "react-router"; export async function loader({ request }) { let header = request.headers.get('Cookie') || ''; @@ -86,8 +85,7 @@ test.describe("Revalidation", () => { `, "app/routes/parent.child.tsx": js` - import { data } from "react-router"; - import { Form, useLoaderData, useRevalidator } from "react-router"; + import { data, Form, useLoaderData, useRevalidator } from "react-router"; export async function action() { return { action: 'data' } diff --git a/integration/scroll-test.ts b/integration/scroll-test.ts index 93f3a1978c..51056397d1 100644 --- a/integration/scroll-test.ts +++ b/integration/scroll-test.ts @@ -15,8 +15,7 @@ test.beforeAll(async () => { fixture = await createFixture({ files: { "app/routes/_index.tsx": js` - import { redirect } from "react-router"; - import { Form } from "react-router"; + import { redirect, Form } from "react-router"; export function action() { return redirect("/test"); diff --git a/integration/transition-test.ts b/integration/transition-test.ts index 1521257e6b..78e3cc16ce 100644 --- a/integration/transition-test.ts +++ b/integration/transition-test.ts @@ -139,8 +139,7 @@ test.describe("rendering", () => { `, "app/routes/gh-1691.tsx": js` - import { json, redirect } from "react-router"; - import { useFetcher} from "react-router"; + import { redirect, useFetcher } from "react-router"; export const action = async ( ) => { return redirect("/gh-1691"); @@ -195,8 +194,7 @@ test.describe("rendering", () => { `, "app/routes/parent.child.tsx": js` - import { redirect } from "react-router"; - import { useFetcher} from "react-router"; + import { redirect, useFetcher } from "react-router"; export const action = async ({ request }) => { return redirect("/parent"); From 90695fb09481acf4646e20a92a5e7fd230e185bb Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 10 Apr 2025 13:46:35 -0400 Subject: [PATCH 13/25] Add `unstable_runClientMiddleware` API for `dataStrategy` (#13395) --- .changeset/silent-snakes-mix.md | 5 ++ .../react-router/lib/dom/ssr/single-fetch.tsx | 74 ++++++++----------- packages/react-router/lib/router/router.ts | 55 ++++++++++++-- packages/react-router/lib/router/utils.ts | 6 +- 4 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 .changeset/silent-snakes-mix.md diff --git a/.changeset/silent-snakes-mix.md b/.changeset/silent-snakes-mix.md new file mode 100644 index 0000000000..cba0468503 --- /dev/null +++ b/.changeset/silent-snakes-mix.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +UNSTABLE: Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 03591c834c..605e55168d 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { decode } from "turbo-stream"; import type { Router as DataRouter } from "../../router/router"; -import { isResponse, runMiddlewarePipeline } from "../../router/router"; +import { isResponse } from "../../router/router"; import type { DataStrategyFunction, DataStrategyFunctionArgs, @@ -148,11 +148,22 @@ export function StreamTransfer({ } } -function handleMiddlewareError(error: unknown, routeId: string) { - return { [routeId]: { type: "error", result: error } }; +export function getSingleFetchDataStrategy( + manifest: AssetsManifest, + ssr: boolean, + basename: string | undefined, + getRouter: () => DataRouter +): DataStrategyFunction { + let dataStrategy = getSingleFetchDataStrategyImpl( + manifest, + ssr, + basename, + getRouter + ); + return async (args) => args.unstable_runClientMiddleware(dataStrategy); } -export function getSingleFetchDataStrategy( +export function getSingleFetchDataStrategyImpl( manifest: AssetsManifest, ssr: boolean, basename: string | undefined, @@ -163,15 +174,16 @@ export function getSingleFetchDataStrategy( // Actions are simple and behave the same for navigations and fetchers if (request.method !== "GET") { - return runMiddlewarePipeline( - args, - false, - () => singleFetchActionStrategy(args, basename), - handleMiddlewareError - ) as Promise>; + return singleFetchActionStrategy(args, basename); } - if (!ssr) { + let foundRevalidatingServerLoader = matches.some( + (m) => + m.unstable_shouldCallHandler() && + manifest.routes[m.route.id]?.hasLoader && + !manifest.routes[m.route.id]?.hasClientLoader + ); + if (!ssr && !foundRevalidatingServerLoader) { // If this is SPA mode, there won't be any loaders below root and we'll // disable single fetch. We have to keep the `dataStrategy` defined for // SPA mode because we may load a SPA fallback page but then navigate into @@ -204,46 +216,22 @@ export function getSingleFetchDataStrategy( // errored otherwise // - So it's safe to make the call knowing there will be a `.data` file on // the other end - let foundRevalidatingServerLoader = matches.some( - (m) => - m.unstable_shouldCallHandler() && - manifest.routes[m.route.id]?.hasLoader && - !manifest.routes[m.route.id]?.hasClientLoader - ); - if (!foundRevalidatingServerLoader) { - return runMiddlewarePipeline( - args, - false, - () => nonSsrStrategy(args, manifest, basename), - handleMiddlewareError - ) as Promise>; - } + return nonSsrStrategy(args, manifest, basename); } // Fetcher loads are singular calls to one loader if (fetcherKey) { - return runMiddlewarePipeline( - args, - false, - () => singleFetchLoaderFetcherStrategy(request, matches, basename), - handleMiddlewareError - ) as Promise>; + return singleFetchLoaderFetcherStrategy(request, matches, basename); } // Navigational loads are more complex... - return runMiddlewarePipeline( + return singleFetchLoaderNavigationStrategy( args, - false, - () => - singleFetchLoaderNavigationStrategy( - args, - manifest, - ssr, - getRouter(), - basename - ), - handleMiddlewareError - ) as Promise>; + manifest, + ssr, + getRouter(), + basename + ); }; } diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index b622f53f5c..b5585cb978 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -2827,7 +2827,8 @@ export function createRouter(init: RouterInit): Router { request, matches, fetcherKey, - scopedContext + scopedContext, + false ); } catch (e) { // If the outer dataStrategy method throws, just return the error for all @@ -4218,7 +4219,8 @@ export function createStaticHandler( request, matches, null, - requestContext + requestContext, + true ); let dataResults: Record = {}; @@ -5546,7 +5548,8 @@ async function callDataStrategyImpl( request: Request, matches: DataStrategyMatch[], fetcherKey: string | null, - scopedContext: unknown + scopedContext: unknown, + isStaticHandler: boolean ): Promise> { // Ensure all middleware is loaded before we start executing routes if (matches.some((m) => m._lazyPromises?.middleware)) { @@ -5556,12 +5559,52 @@ async function callDataStrategyImpl( // Send all matches here to allow for a middleware-type implementation. // handler will be a no-op for unneeded routes and we filter those results // back out below. - let results = await dataStrategyImpl({ - matches, + let dataStrategyArgs = { request, params: matches[0].params, - fetcherKey, context: scopedContext, + matches, + }; + let unstable_runClientMiddleware = isStaticHandler + ? () => { + throw new Error( + "You cannot call `unstable_runClientMiddleware()` from a static handler " + + "`dataStrategy`. Middleware is run outside of `dataStrategy` during " + + "SSR in order to bubble up the Response. You can enable middleware " + + "via the `respond` API in `query`/`queryRoute`" + ); + } + : (cb: DataStrategyFunction) => { + let typedDataStrategyArgs = dataStrategyArgs as ( + | LoaderFunctionArgs + | ActionFunctionArgs + ) & { + matches: DataStrategyMatch[]; + }; + return runMiddlewarePipeline( + typedDataStrategyArgs, + false, + () => + cb({ + ...typedDataStrategyArgs, + fetcherKey, + unstable_runClientMiddleware: () => { + throw new Error( + "Cannot call `unstable_runClientMiddleware()` from within an " + + "`unstable_runClientMiddleware` handler" + ); + }, + }), + (error: unknown, routeId: string) => ({ + [routeId]: { type: "error", result: error }, + }) + ) as Promise>; + }; + + let results = await dataStrategyImpl({ + ...dataStrategyArgs, + fetcherKey, + unstable_runClientMiddleware, }); // Wait for all routes to load here but swallow the error since we want diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 8de2c979d5..cebea7406f 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -347,7 +347,6 @@ export interface DataStrategyMatch shouldLoad: boolean; // This can be null for actions calls and for initial hydration calls unstable_shouldRevalidateArgs: ShouldRevalidateFunctionArgs | null; - // TODO: Figure out a good name for this or use `shouldLoad` and add a future flag // This function will use a scoped version of `shouldRevalidateArgs` because // they are read-only but let the user provide an optional override value for // `defaultShouldRevalidate` if they choose @@ -362,8 +361,9 @@ export interface DataStrategyMatch export interface DataStrategyFunctionArgs extends DataFunctionArgs { matches: DataStrategyMatch[]; - // TODO: Implement - // runMiddleware: () => unknown, + unstable_runClientMiddleware: ( + cb: DataStrategyFunction + ) => Promise>; fetcherKey: string | null; } From 18f1b709eda3d899336400053743dd9e676db51f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 10 Apr 2025 15:52:12 -0400 Subject: [PATCH 14/25] Refactor dataStrategy for better use with RSC (#13396) --- .../lib/dom-export/hydrated-router.tsx | 19 +- .../react-router/lib/dom/ssr/single-fetch.tsx | 210 +++++++++--------- 2 files changed, 125 insertions(+), 104 deletions(-) diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 4b8efc84c7..ea3dddc7db 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -208,10 +208,23 @@ function createHydratedRouter({ unstable_middleware: ssrInfo.context.future.unstable_middleware, }, dataStrategy: getSingleFetchDataStrategy( - ssrInfo.manifest, + () => router, + (routeId: string) => { + let manifestRoute = ssrInfo!.manifest.routes[routeId]; + invariant(manifestRoute, "Route not found in manifest/routeModules"); + let routeModule = ssrInfo!.routeModules[routeId]; + return { + hasLoader: manifestRoute.hasLoader, + hasClientLoader: manifestRoute.hasClientLoader, + // In some cases the module may not be loaded yet and we don't care + // if it's got shouldRevalidate or not + hasShouldRevalidate: routeModule + ? routeModule.shouldRevalidate != null + : undefined, + }; + }, ssrInfo.context.ssr, - ssrInfo.context.basename, - () => router + ssrInfo.context.basename ), patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( ssrInfo.manifest, diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 605e55168d..8c79d276d7 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -6,7 +6,6 @@ import type { DataStrategyFunction, DataStrategyFunctionArgs, DataStrategyResult, - DataStrategyMatch, } from "../../router/utils"; import { ErrorResponseImpl, @@ -16,7 +15,7 @@ import { stripBasename, } from "../../router/utils"; import { createRequestInit } from "./data"; -import type { AssetsManifest, EntryContext } from "./entry"; +import type { EntryContext } from "./entry"; import { escapeHtml } from "./markup"; import invariant from "./invariant"; @@ -148,41 +147,54 @@ export function StreamTransfer({ } } +type GetRouteInfoFunction = (routeId: string) => { + hasLoader: boolean; + hasClientLoader: boolean; // TODO: Can this be read from match.route? + hasShouldRevalidate: boolean | undefined; // TODO: Can this be read from match.route? +}; + +type FetchAndDecodeFunction = ( + request: Request, + basename: string | undefined, + targetRoutes?: string[] +) => Promise<{ status: number; data: DecodedSingleFetchResults }>; + export function getSingleFetchDataStrategy( - manifest: AssetsManifest, + getRouter: () => DataRouter, + getRouteInfo: GetRouteInfoFunction, ssr: boolean, - basename: string | undefined, - getRouter: () => DataRouter + basename: string | undefined ): DataStrategyFunction { let dataStrategy = getSingleFetchDataStrategyImpl( - manifest, + getRouter, + getRouteInfo, + fetchAndDecodeViaTurboStream, ssr, - basename, - getRouter + basename ); return async (args) => args.unstable_runClientMiddleware(dataStrategy); } export function getSingleFetchDataStrategyImpl( - manifest: AssetsManifest, + getRouter: () => DataRouter, + getRouteInfo: GetRouteInfoFunction, + fetchAndDecode: FetchAndDecodeFunction, ssr: boolean, - basename: string | undefined, - getRouter: () => DataRouter + basename: string | undefined ): DataStrategyFunction { return async (args) => { let { request, matches, fetcherKey } = args; + let router = getRouter(); // Actions are simple and behave the same for navigations and fetchers if (request.method !== "GET") { - return singleFetchActionStrategy(args, basename); + return singleFetchActionStrategy(args, fetchAndDecode, basename); } - let foundRevalidatingServerLoader = matches.some( - (m) => - m.unstable_shouldCallHandler() && - manifest.routes[m.route.id]?.hasLoader && - !manifest.routes[m.route.id]?.hasClientLoader - ); + let foundRevalidatingServerLoader = matches.some((m) => { + let { hasLoader, hasClientLoader } = getRouteInfo(m.route.id); + return m.unstable_shouldCallHandler() && hasLoader && !hasClientLoader; + }); if (!ssr && !foundRevalidatingServerLoader) { // If this is SPA mode, there won't be any loaders below root and we'll // disable single fetch. We have to keep the `dataStrategy` defined for @@ -216,20 +228,26 @@ export function getSingleFetchDataStrategyImpl( // errored otherwise // - So it's safe to make the call knowing there will be a `.data` file on // the other end - return nonSsrStrategy(args, manifest, basename); + return nonSsrStrategy(args, getRouteInfo, fetchAndDecode, basename); } // Fetcher loads are singular calls to one loader if (fetcherKey) { - return singleFetchLoaderFetcherStrategy(request, matches, basename); + return singleFetchLoaderFetcherStrategy( + request, + matches, + fetchAndDecode, + basename + ); } // Navigational loads are more complex... return singleFetchLoaderNavigationStrategy( args, - manifest, + router, + getRouteInfo, + fetchAndDecode, ssr, - getRouter(), basename ); }; @@ -239,6 +257,7 @@ export function getSingleFetchDataStrategyImpl( // navigations and fetchers) async function singleFetchActionStrategy( { request, matches }: DataStrategyFunctionArgs, + fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { let actionMatch = matches.find((m) => m.unstable_shouldCallHandler()); @@ -246,13 +265,9 @@ async function singleFetchActionStrategy( let actionStatus: number | undefined = undefined; let result = await actionMatch.resolve(async (handler) => { let result = await handler(async () => { - let url = singleFetchUrl(request.url, basename); - let init = await createRequestInit(request); - let { data, status } = await fetchAndDecode( - url, - init, - actionMatch!.route.id - ); + let { data, status } = await fetchAndDecode(request, basename, [ + actionMatch!.route.id, + ]); actionStatus = status; return unwrapSingleFetchResult(data, actionMatch!.route.id); }); @@ -276,22 +291,28 @@ async function singleFetchActionStrategy( // We want to opt-out of Single Fetch when we aren't in SSR mode async function nonSsrStrategy( { request, matches }: DataStrategyFunctionArgs, - manifest: AssetsManifest, + getRouteInfo: GetRouteInfoFunction, + fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { let matchesToLoad = matches.filter((m) => m.unstable_shouldCallHandler()); - let url = stripIndexParam(singleFetchUrl(request.url, basename)); - let init = await createRequestInit(request); let results: Record = {}; await Promise.all( matchesToLoad.map((m) => m.resolve(async (handler) => { try { + let { hasClientLoader } = getRouteInfo(m.route.id); // Need to pass through a `singleFetch` override handler so // clientLoader's can still call server loaders through `.data` // requests - let result = manifest.routes[m.route.id]?.hasClientLoader - ? await fetchSingleLoader(handler, url, init, m.route.id) + let routeId = m.route.id; + let result = hasClientLoader + ? await handler(async () => { + let { data } = await fetchAndDecode(request, basename, [ + routeId, + ]); + return unwrapSingleFetchResult(data, routeId); + }) : await handler(); results[m.route.id] = { type: "data", result }; } catch (e) { @@ -307,9 +328,10 @@ async function nonSsrStrategy( // create a singular promise for all server-loader routes to latch onto. async function singleFetchLoaderNavigationStrategy( { request, matches }: DataStrategyFunctionArgs, - manifest: AssetsManifest, - ssr: boolean, router: DataRouter, + getRouteInfo: GetRouteInfoFunction, + fetchAndDecode: FetchAndDecodeFunction, + ssr: boolean, basename: string | undefined ) { // Track which routes need a server load for use in a `_routes` param @@ -324,10 +346,6 @@ async function singleFetchLoaderNavigationStrategy( // Deferred we'll use for the singleular call to the server let singleFetchDfd = createDeferred(); - // Base URL and RequestInit for calls to the server - let url = stripIndexParam(singleFetchUrl(request.url, basename)); - let init = await createRequestInit(request); - // We'll build up this results object as we loop through matches let results: Record = {}; @@ -335,9 +353,9 @@ async function singleFetchLoaderNavigationStrategy( matches.map(async (m, i) => m.resolve(async (handler) => { routeDfds[i].resolve(); - - let manifestRoute = manifest.routes[m.route.id]; - invariant(manifestRoute, "No manifest route found for dataStrategy"); + let routeId = m.route.id; + let { hasLoader, hasClientLoader, hasShouldRevalidate } = + getRouteInfo(routeId); let defaultShouldRevalidate = !m.unstable_shouldRevalidateArgs || @@ -349,51 +367,44 @@ async function singleFetchLoaderNavigationStrategy( // If this route opted out, don't include in the .data request foundOptOutRoute ||= m.unstable_shouldRevalidateArgs != null && // This is a revalidation, - manifestRoute?.hasLoader === true && // for a route with a server loader, - m.route.shouldRevalidate != null; // and a shouldRevalidate function + hasLoader && // for a route with a server loader, + hasShouldRevalidate === true; // and a shouldRevalidate function return; } // When a route has a client loader, it opts out of the singular call and // calls it's server loader via `serverLoader()` using a `?_routes` param - if (manifestRoute.hasClientLoader) { - if (manifestRoute.hasLoader) { + if (hasClientLoader) { + if (hasLoader) { foundOptOutRoute = true; } try { - let result = await fetchSingleLoader( - handler, - url, - init, - m.route.id - ); - results[m.route.id] = { type: "data", result }; + let result = await handler(async () => { + let { data } = await fetchAndDecode(request, basename, [routeId]); + return unwrapSingleFetchResult(data, routeId); + }); + + results[routeId] = { type: "data", result }; } catch (e) { - results[m.route.id] = { type: "error", result: e }; + results[routeId] = { type: "error", result: e }; } return; } // Load this route on the server if it has a loader - if (manifestRoute && manifestRoute.hasLoader) { - routesParams.add(m.route.id); + if (hasLoader) { + routesParams.add(routeId); } // Lump this match in with the others on a singular promise try { let result = await handler(async () => { let data = await singleFetchDfd.promise; - return unwrapSingleFetchResult(data, m.route.id); + return unwrapSingleFetchResult(data, routeId); }); - results[m.route.id] = { - type: "data", - result, - }; + results[routeId] = { type: "data", result }; } catch (e) { - results[m.route.id] = { - type: "error", - result: e, - }; + results[routeId] = { type: "error", result: e }; } }) ) @@ -417,13 +428,12 @@ async function singleFetchLoaderNavigationStrategy( } else { // When routes have opted out, add a `_routes` param to filter server loaders // Skipped in `ssr:false` because we expect to be loading static `.data` files - if (ssr && foundOptOutRoute && routesParams.size > 0) { - let routes = [...routesParams.keys()].join(","); - url.searchParams.set("_routes", routes); - } - + let targetRoutes = + ssr && foundOptOutRoute && routesParams.size > 0 + ? [...routesParams.keys()] + : undefined; try { - let data = await fetchAndDecode(url, init); + let data = await fetchAndDecode(request, basename, targetRoutes); singleFetchDfd.resolve(data.data); } catch (e) { singleFetchDfd.reject(e); @@ -439,34 +449,21 @@ async function singleFetchLoaderNavigationStrategy( async function singleFetchLoaderFetcherStrategy( request: Request, matches: DataStrategyFunctionArgs["matches"], + fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { let fetcherMatch = matches.find((m) => m.unstable_shouldCallHandler()); invariant(fetcherMatch, "No fetcher match found"); - let result = await fetcherMatch.resolve(async (handler) => { - let url = stripIndexParam(singleFetchUrl(request.url, basename)); - let init = await createRequestInit(request); - return fetchSingleLoader(handler, url, init, fetcherMatch!.route.id); - }); + let routeId = fetcherMatch.route.id; + let result = await fetcherMatch.resolve(async (handler) => + handler(async () => { + let { data } = await fetchAndDecode(request, basename, [routeId]); + return unwrapSingleFetchResult(data, routeId); + }) + ); return { [fetcherMatch.route.id]: result }; } -function fetchSingleLoader( - handler: Parameters< - NonNullable[0]> - >[0], - url: URL, - init: RequestInit, - routeId: string -) { - return handler(async () => { - let singleLoaderUrl = new URL(url); - singleLoaderUrl.searchParams.set("_routes", routeId); - let { data } = await fetchAndDecode(singleLoaderUrl, init); - return unwrapSingleFetchResult(data, routeId); - }); -} - function stripIndexParam(url: URL) { let indexValues = url.searchParams.getAll("index"); url.searchParams.delete("index"); @@ -510,12 +507,20 @@ export function singleFetchUrl( return url; } -async function fetchAndDecode( - url: URL, - init: RequestInit, - routeId?: string +async function fetchAndDecodeViaTurboStream( + request: Request, + basename: string | undefined, + targetRoutes?: string[] ): Promise<{ status: number; data: DecodedSingleFetchResults }> { - let res = await fetch(url, init); + let url = singleFetchUrl(request.url, basename); + if (request.method === "GET") { + url = stripIndexParam(url); + if (targetRoutes) { + url.searchParams.set("_routes", targetRoutes.join(",")); + } + } + + let res = await fetch(url, await createRequestInit(request)); // If this 404'd without hitting the running server (most likely in a // pre-rendered app using a CDN), then bubble a standard 404 ErrorResponse @@ -525,8 +530,10 @@ async function fetchAndDecode( if (NO_BODY_STATUS_CODES.has(res.status)) { let routes: { [key: string]: SingleFetchResult } = {}; - if (routeId) { - routes[routeId] = { data: undefined }; + // We get back just a single result for action requests - normalize that + // to a DecodedSingleFetchResults shape here + if (targetRoutes && request.method !== "GET") { + routes[targetRoutes[0]] = { data: undefined }; } return { status: res.status, @@ -539,7 +546,7 @@ async function fetchAndDecode( try { let decoded = await decodeViaTurboStream(res.body, window); let data: DecodedSingleFetchResults; - if (!init.method || init.method === "GET") { + if (request.method === "GET") { let typed = decoded.value as SingleFetchResults; if (SingleFetchRedirectSymbol in typed) { data = { redirect: typed[SingleFetchRedirectSymbol] }; @@ -548,6 +555,7 @@ async function fetchAndDecode( } } else { let typed = decoded.value as SingleFetchResult; + let routeId = targetRoutes?.[0]; invariant(routeId, "No routeId found for single fetch call decoding"); if ("redirect" in typed) { data = { redirect: typed }; From 65774eafc111322416572901e1d6a3e329327881 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 11 Apr 2025 13:53:49 +1000 Subject: [PATCH 15/25] Support rolldown-vite in integration tests (#13388) --- integration/fs-routes-test.ts | 8 - integration/helpers/create-fixture.ts | 8 +- .../helpers/vite-rolldown-template/.gitignore | 6 + .../vite-rolldown-template/app/root.tsx | 19 + .../vite-rolldown-template/app/routes.ts | 4 + .../app/routes/_index.tsx | 16 + .../helpers/vite-rolldown-template/env.d.ts | 2 + .../vite-rolldown-template/package.json | 45 + .../vite-rolldown-template/public/favicon.ico | Bin 0 -> 15086 bytes .../vite-rolldown-template/tsconfig.json | 22 + .../vite-rolldown-template/vite.config.ts | 11 + integration/helpers/vite.ts | 58 +- integration/prefetch-test.ts | 1060 +++++++++-------- integration/vite-basename-test.ts | 15 +- integration/vite-build-test.ts | 679 +++++------ integration/vite-css-test.ts | 29 +- integration/vite-presets-test.ts | 235 ++-- ...e-route-exports-modified-offscreen-test.ts | 17 +- packages/react-router-dev/vite/plugin.ts | 55 +- .../framework-rolldown-vite/vite.config.ts | 22 +- pnpm-lock.yaml | 299 +++++ 21 files changed, 1582 insertions(+), 1028 deletions(-) create mode 100644 integration/helpers/vite-rolldown-template/.gitignore create mode 100644 integration/helpers/vite-rolldown-template/app/root.tsx create mode 100644 integration/helpers/vite-rolldown-template/app/routes.ts create mode 100644 integration/helpers/vite-rolldown-template/app/routes/_index.tsx create mode 100644 integration/helpers/vite-rolldown-template/env.d.ts create mode 100644 integration/helpers/vite-rolldown-template/package.json create mode 100644 integration/helpers/vite-rolldown-template/public/favicon.ico create mode 100644 integration/helpers/vite-rolldown-template/tsconfig.json create mode 100644 integration/helpers/vite-rolldown-template/vite.config.ts diff --git a/integration/fs-routes-test.ts b/integration/fs-routes-test.ts index 5708622e2a..03d5a723c3 100644 --- a/integration/fs-routes-test.ts +++ b/integration/fs-routes-test.ts @@ -19,14 +19,6 @@ test.describe("fs-routes", () => { test.beforeAll(async () => { fixture = await createFixture({ files: { - "vite.config.js": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - plugins: [reactRouter()], - }); - `, "app/routes.ts": js` import { type RouteConfig } from "@react-router/dev/routes"; import { flatRoutes } from "@react-router/fs-routes"; diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 672faef6dd..bd482ba731 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -18,7 +18,7 @@ import { import { createRequestHandler as createExpressHandler } from "@react-router/express"; import { createReadableStreamFromReadable } from "@react-router/node"; -import { viteConfig, reactRouterConfig } from "./vite.js"; +import { type TemplateName, viteConfig, reactRouterConfig } from "./vite.js"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const root = path.join(__dirname, "../.."); @@ -31,6 +31,7 @@ export interface FixtureInit { spaMode?: boolean; prerender?: boolean; port?: number; + templateName?: TemplateName; } export type Fixture = Awaited>; @@ -362,7 +363,7 @@ export async function createFixtureProject( init: FixtureInit = {}, mode?: ServerMode ): Promise { - let template = "vite-5-template"; + let template = init.templateName ?? "vite-5-template"; let integrationTemplateDir = path.resolve(__dirname, template); let projectName = `rr-${template}-${Math.random().toString(32).slice(2)}`; let projectDir = path.join(TMP_DIR, projectName); @@ -424,6 +425,9 @@ function build(projectDir: string, buildStdio?: Writable, mode?: ServerMode) { env: { ...process.env, NODE_ENV: mode || ServerMode.Production, + // Ensure build can pass in Rolldown. This can be removed once + // "preserveEntrySignatures" is supported in rolldown-vite. + ROLLDOWN_OPTIONS_VALIDATION: "loose", }, }); diff --git a/integration/helpers/vite-rolldown-template/.gitignore b/integration/helpers/vite-rolldown-template/.gitignore new file mode 100644 index 0000000000..c08251ce0e --- /dev/null +++ b/integration/helpers/vite-rolldown-template/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +.env +.react-router diff --git a/integration/helpers/vite-rolldown-template/app/root.tsx b/integration/helpers/vite-rolldown-template/app/root.tsx new file mode 100644 index 0000000000..b36392b4dd --- /dev/null +++ b/integration/helpers/vite-rolldown-template/app/root.tsx @@ -0,0 +1,19 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/vite-rolldown-template/app/routes.ts b/integration/helpers/vite-rolldown-template/app/routes.ts new file mode 100644 index 0000000000..4c05936cb6 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/app/routes.ts @@ -0,0 +1,4 @@ +import { type RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default flatRoutes() satisfies RouteConfig; diff --git a/integration/helpers/vite-rolldown-template/app/routes/_index.tsx b/integration/helpers/vite-rolldown-template/app/routes/_index.tsx new file mode 100644 index 0000000000..ecfc25c614 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/app/routes/_index.tsx @@ -0,0 +1,16 @@ +import type { MetaFunction } from "react-router"; + +export const meta: MetaFunction = () => { + return [ + { title: "New React Router App" }, + { name: "description", content: "Welcome to React Router!" }, + ]; +}; + +export default function Index() { + return ( +
+

Welcome to React Router

+
+ ); +} diff --git a/integration/helpers/vite-rolldown-template/env.d.ts b/integration/helpers/vite-rolldown-template/env.d.ts new file mode 100644 index 0000000000..5e7dfe5dd9 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/integration/helpers/vite-rolldown-template/package.json b/integration/helpers/vite-rolldown-template/package.json new file mode 100644 index 0000000000..01dd8b78a8 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/package.json @@ -0,0 +1,45 @@ +{ + "name": "integration-vite-rolldown-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "react-router dev", + "build": "cross-env ROLLDOWN_OPTIONS_VALIDATION=loose react-router build", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@react-router/express": "workspace:*", + "@react-router/node": "workspace:*", + "@react-router/serve": "workspace:*", + "@vanilla-extract/css": "^1.10.0", + "@vanilla-extract/vite-plugin": "^3.9.2", + "express": "^4.19.2", + "isbot": "^5.1.11", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router": "workspace:*", + "serialize-javascript": "^6.0.1" + }, + "devDependencies": { + "@react-router/dev": "workspace:*", + "@react-router/fs-routes": "workspace:*", + "@react-router/remix-routes-option-adapter": "workspace:*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "cross-env": "^7.0.3", + "eslint": "^8.38.0", + "typescript": "^5.1.6", + "vite": "npm:rolldown-vite@6.3.0-beta.5", + "vite-env-only": "^3.0.1", + "vite-tsconfig-paths": "^4.2.1" + }, + "overrides": { + "vite": "npm:rolldown-vite@6.3.0-beta.5" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/integration/helpers/vite-rolldown-template/public/favicon.ico b/integration/helpers/vite-rolldown-template/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 diff --git a/integration/helpers/vite-rolldown-template/tsconfig.json b/integration/helpers/vite-rolldown-template/tsconfig.json new file mode 100644 index 0000000000..62bbb55722 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true, + "rootDirs": [".", ".react-router/types/"] + } +} diff --git a/integration/helpers/vite-rolldown-template/vite.config.ts b/integration/helpers/vite-rolldown-template/vite.config.ts new file mode 100644 index 0000000000..fac933f23c --- /dev/null +++ b/integration/helpers/vite-rolldown-template/vite.config.ts @@ -0,0 +1,11 @@ +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + // @ts-expect-error `dev` depends on Vite 6, Plugin type is mismatched. + reactRouter(), + tsconfigPaths(), + ], +}); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index be6e7bc0a8..1330c291e1 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -61,14 +61,29 @@ export const reactRouterConfig = ({ `; }; -type ViteConfigArgs = { +type ViteConfigServerArgs = { port: number; fsAllow?: string[]; +}; + +type ViteConfigBuildArgs = { + assetsInlineLimit?: number; + assetsDir?: string; +}; + +type ViteConfigBaseArgs = { envDir?: string; }; +type ViteConfigArgs = ( + | ViteConfigServerArgs + | { [K in keyof ViteConfigServerArgs]?: never } +) & + ViteConfigBuildArgs & + ViteConfigBaseArgs; + export const viteConfig = { - server: async (args: ViteConfigArgs) => { + server: async (args: ViteConfigServerArgs) => { let { port, fsAllow } = args; let hmrPort = await getPort(); let text = dedent` @@ -81,21 +96,42 @@ export const viteConfig = { `; return text; }, + build: ({ assetsInlineLimit, assetsDir }: ViteConfigBuildArgs = {}) => { + return dedent` + build: { + // Detect rolldown-vite. This should ideally use "rolldownVersion" + // but that's not exported. Once that's available, this + // check should be updated to use it. + rollupOptions: "transformWithOxc" in (await import("vite")) + ? { + onwarn(warning, warn) { + // Ignore "The built-in minifier is still under development." warning + if (warning.code === "MINIFY_WARNING") return; + warn(warning); + }, + } + : undefined, + assetsInlineLimit: ${assetsInlineLimit ?? "undefined"}, + assetsDir: ${assetsDir ? `"${assetsDir}"` : "undefined"}, + }, + `; + }, basic: async (args: ViteConfigArgs) => { return dedent` import { reactRouter } from "@react-router/dev/vite"; import { envOnlyMacros } from "vite-env-only"; import tsconfigPaths from "vite-tsconfig-paths"; - export default { - ${await viteConfig.server(args)} + export default async () => ({ + ${args.port ? await viteConfig.server(args) : ""} + ${viteConfig.build(args)} envDir: ${args.envDir ? `"${args.envDir}"` : "undefined"}, plugins: [ reactRouter(), envOnlyMacros(), tsconfigPaths() ], - }; + }); `; }, }; @@ -145,14 +181,19 @@ export const EXPRESS_SERVER = (args: { `; export type TemplateName = + | "cloudflare-dev-proxy-template" | "vite-5-template" | "vite-6-template" - | "cloudflare-dev-proxy-template" - | "vite-plugin-cloudflare-template"; + | "vite-plugin-cloudflare-template" + | "vite-rolldown-template"; export const viteMajorTemplates = [ { templateName: "vite-5-template", templateDisplayName: "Vite 5" }, { templateName: "vite-6-template", templateDisplayName: "Vite 6" }, + { + templateName: "vite-rolldown-template", + templateDisplayName: "Vite Rolldown", + }, ] as const satisfies Array<{ templateName: TemplateName; templateDisplayName: string; @@ -205,6 +246,9 @@ export const build = ({ ...process.env, ...colorEnv, ...env, + // Ensure build can pass in Rolldown. This can be removed once + // "preserveEntrySignatures" is supported in rolldown-vite. + ROLLDOWN_OPTIONS_VALIDATION: "loose", }, }); }; diff --git a/integration/prefetch-test.ts b/integration/prefetch-test.ts index 211030f1d7..bee1b75991 100644 --- a/integration/prefetch-test.ts +++ b/integration/prefetch-test.ts @@ -12,12 +12,17 @@ import type { AppFixture, } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { type TemplateName, viteMajorTemplates } from "./helpers/vite.js"; type PrefetchType = "intent" | "render" | "none" | "viewport"; // Generate the test app using the given prefetch mode -function fixtureFactory(mode: PrefetchType): FixtureInit { +function fixtureFactory( + mode: PrefetchType, + templateName: TemplateName +): FixtureInit { return { + templateName, files: { "app/root.tsx": js` import { @@ -84,530 +89,561 @@ function fixtureFactory(mode: PrefetchType): FixtureInit { }; } -test.describe("prefetch=none", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("none")); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); - - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); -}); - -test.describe("prefetch=render", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("render")); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); - - test("adds prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // Both data and asset fetch for /with-loader - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", - { state: "attached" } - ); - - // Only asset fetch for /without-loader - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", - { state: "attached" } - ); - - // 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( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - - // Ensure no other links in the #nav element - expect(await page.locator("#nav link").count()).toBe(7); - }); -}); - -test.describe("prefetch=intent (hover)", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("intent")); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); - - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); - - test("adds prefetch tags on hover", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.hover("a[href='http://github.com/with-loader']"); - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", - { state: "attached" } - ); - // Check href prefix due to hashed filenames - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(4); - - await page.hover("a[href='http://github.com/without-loader']"); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(3); - }); - - test("removes prefetch tags after navigating to/from the page", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // Links added on hover - await page.hover("a[href='http://github.com/with-loader']"); - await page.waitForSelector("#nav link", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(4); - - // Links removed upon navigating to the page - await page.click("a[href='http://github.com/with-loader']"); - await page.waitForSelector("h2.with-loader", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(0); - - // Links stay removed upon navigating away from the page - await page.click("a[href='http://github.com/without-loader']"); - await page.waitForSelector("h2.without-loader", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(0); - }); -}); - -test.describe("prefetch=intent (focus)", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("intent")); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); +test.describe("prefetch", () => { + viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => { + test.describe(templateDisplayName, () => { + test.describe("prefetch=none", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("none", templateName)); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + }); - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); + test.describe("prefetch=render", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("render", templateName)); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Both data and asset fetch for /with-loader + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", + { state: "attached" } + ); - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); + // Only asset fetch for /without-loader + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", + { state: "attached" } + ); - test("adds prefetch tags on focus", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - // This click is needed to transfer focus to the main window, allowing - // subsequent focus events to fire - await page.click("body"); - await page.focus("a[href='http://github.com/with-loader']"); - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(4); - - await page.focus("a[href='http://github.com/without-loader']"); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(3); - }); -}); + // 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( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); -test.describe("prefetch=viewport", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/_index.tsx": js` - import { Link } from "react-router"; - - export default function Component() { - return ( - <> -

Index Page - Scroll Down

-
- Click me! -
- - ); - } - `, - - "app/routes/test.tsx": js` - export function loader() { - return null; - } - export default function Component() { - return

Test Page

; - } - `, - }, - }); + // Ensure no other links in the #nav element + expect(await page.locator("#nav link").count()).toBe(7); + }); + }); - // This creates an interactive app using puppeteer. - appFixture = await createAppFixture(fixture); - }); + test.describe("prefetch=intent (hover)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("intent", templateName)); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on hover", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='http://github.com/with-loader']"); + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", + { state: "attached" } + ); + // Check href prefix due to hashed filenames + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(4); - test.afterAll(() => { - appFixture.close(); - }); + await page.hover("a[href='http://github.com/without-loader']"); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(3); + }); + + test("removes prefetch tags after navigating to/from the page", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Links added on hover + await page.hover("a[href='http://github.com/with-loader']"); + await page.waitForSelector("#nav link", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(4); + + // Links removed upon navigating to the page + await page.click("a[href='http://github.com/with-loader']"); + await page.waitForSelector("h2.with-loader", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(0); + + // Links stay removed upon navigating away from the page + await page.click("a[href='http://github.com/without-loader']"); + await page.waitForSelector("h2.without-loader", { + state: "attached", + }); + expect(await page.locator("#nav link").count()).toBe(0); + }); + }); - test("should prefetch when the link enters the viewport", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // No preloads to start - await expect(page.locator("div link")).toHaveCount(0); - - // Preloads render on scroll down - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - - await page.waitForSelector( - "div link[rel='prefetch'][as='fetch'][href='http://github.com/test.data']", - { state: "attached" } - ); - await page.waitForSelector( - "div link[rel='modulepreload'][href^='/assets/test-']", - { state: "attached" } - ); - - // Preloads removed on scroll up - await page.evaluate(() => window.scrollTo(0, 0)); - await expect(page.locator("div link")).toHaveCount(0); - }); -}); + test.describe("prefetch=intent (focus)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("intent", templateName)); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on focus", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + // This click is needed to transfer focus to the main window, allowing + // subsequent focus events to fire + await page.click("body"); + await page.focus("a[href='http://github.com/with-loader']"); + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(4); -test.describe("other scenarios", () => { - let fixture: Fixture; - let appFixture: AppFixture; + await page.focus("a[href='http://github.com/without-loader']"); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(3); + }); + }); - test.afterAll(() => { - appFixture?.close(); - }); + test.describe("prefetch=viewport", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Component() { + return ( + <> +

Index Page - Scroll Down

+
+ Click me! +
+ + ); + } + `, + + "app/routes/test.tsx": js` + export function loader() { + return null; + } + export default function Component() { + return

Test Page

; + } + `, + }, + }); + + // This creates an interactive app using puppeteer. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should prefetch when the link enters the viewport", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // No preloads to start + await expect(page.locator("div link")).toHaveCount(0); + + // Preloads render on scroll down + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight) + ); - test("does not add prefetch links for stylesheets already in the DOM (active routes)", async ({ - page, - }) => { - fixture = await createFixture({ - files: { - "app/root.tsx": js` - import { Links, Meta, Scripts, useFetcher } from "react-router"; - import globalCss from "./global.css?url"; - - export function links() { - return [{ rel: "stylesheet", href: globalCss }]; - } - - export async function action() { - return null; - } - - export async function loader() { - return null; - } - - export default function Root() { - let fetcher = useFetcher(); - - return ( - - - - - - - -

{fetcher.state}

- - - - ); - } - `, - - "app/global.css": ` - body { - background-color: black; - color: white; - } - `, - - "app/routes/_index.tsx": js` - export default function() { - return

Index

; - } - `, - }, - }); - appFixture = await createAppFixture(fixture); - let requests: { type: string; url: string }[] = []; + await page.waitForSelector( + "div link[rel='prefetch'][as='fetch'][href='http://github.com/test.data']", + { state: "attached" } + ); + await page.waitForSelector( + "div link[rel='modulepreload'][href^='/assets/test-']", + { state: "attached" } + ); - page.on("request", (req) => { - requests.push({ - type: req.resourceType(), - url: req.url(), + // Preloads removed on scroll up + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(page.locator("div link")).toHaveCount(0); + }); }); - }); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.click("#submit-fetcher"); - await page.waitForSelector("#fetcher-state--idle"); - // We should not send a second request for this root stylesheet that's - // already been rendered in the DOM - let stylesheets = requests.filter( - (r) => r.type === "stylesheet" && /\/global-[a-z0-9]+\.css/i.test(r.url) - ); - expect(stylesheets.length).toBe(1); - }); - - test("dedupes prefetch tags", async ({ page }) => { - fixture = await createFixture({ - files: { - "app/root.tsx": js` - import { - Link, - Links, - Meta, - Outlet, - Scripts, - useLoaderData, - } from "react-router"; - - export default function Root() { - const styles = - 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + - 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; - - return ( - - - - - - - -

Root

- - - - - - ); - } - `, - - "app/global.css": css` - .global-class { - background-color: gray; - color: black; - } - `, - - "app/local.css": css` - .local-class { - background-color: black; - color: white; - } - `, - - "app/routes/_index.tsx": js` - export default function() { - return

Index

; - } - `, - - "app/routes/with-nested-links.tsx": js` - import { Outlet } from "react-router"; - import globalCss from "../global.css?url"; - - export function links() { - return [ - // Same links as child route but with different key order - { - rel: "stylesheet", - href: globalCss, - }, - { - rel: "preload", - as: "image", - imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", - imageSizes: "9999px", - }, - ]; - } - export default function() { - return ; - } - `, - - "app/routes/with-nested-links.nested.tsx": js` - import globalCss from '../global.css?url'; - import localCss from '../local.css?url'; - - export function links() { - return [ - // Same links as parent route but with different key order - { - href: globalCss, - rel: "stylesheet", - }, - { - imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", - imageSizes: "9999px", - rel: "preload", - as: "image", - }, - // Unique links for child route - { - rel: "stylesheet", - href: localCss, - }, - { - rel: "preload", - as: "image", - imageSrcSet: "image-700.jpg 700w, image-1400.jpg 1400w", - imageSizes: "9999px", - }, - ]; - } - export default function() { - return

With Nested Links

; - } - `, - }, - }); - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.hover("a[href='http://github.com/with-nested-links/nested']"); - await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { - state: "attached", + test.describe("other scenarios", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture?.close(); + }); + + test("does not add prefetch links for stylesheets already in the DOM (active routes)", async ({ + page, + }) => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Scripts, useFetcher } from "react-router"; + import globalCss from "./global.css?url"; + + export function links() { + return [{ rel: "stylesheet", href: globalCss }]; + } + + export async function action() { + return null; + } + + export async function loader() { + return null; + } + + export default function Root() { + let fetcher = useFetcher(); + + return ( + + + + + + + +

{fetcher.state}

+ + + + ); + } + `, + + "app/global.css": ` + body { + background-color: black; + color: white; + } + `, + + "app/routes/_index.tsx": js` + export default function() { + return

Index

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + let requests: { type: string; url: string }[] = []; + + page.on("request", (req) => { + requests.push({ + type: req.resourceType(), + url: req.url(), + }); + }); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.click("#submit-fetcher"); + await page.waitForSelector("#fetcher-state--idle"); + // We should not send a second request for this root stylesheet that's + // already been rendered in the DOM + let stylesheets = requests.filter( + (r) => + r.type === "stylesheet" && /\/global-[a-z0-9-]+\.css/i.test(r.url) + ); + expect(stylesheets.length).toBe(1); + }); + + test("dedupes prefetch tags", async ({ page }) => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "react-router"; + + export default function Root() { + const styles = + 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + + 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; + + return ( + + + + + + + +

Root

+ + + + + + ); + } + `, + + "app/global.css": css` + .global-class { + background-color: gray; + color: black; + } + `, + + "app/local.css": css` + .local-class { + background-color: black; + color: white; + } + `, + + "app/routes/_index.tsx": js` + export default function() { + return

Index

; + } + `, + + "app/routes/with-nested-links.tsx": js` + import { Outlet } from "react-router"; + import globalCss from "../global.css?url"; + + export function links() { + return [ + // Same links as child route but with different key order + { + rel: "stylesheet", + href: globalCss, + }, + { + rel: "preload", + as: "image", + imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", + imageSizes: "9999px", + }, + ]; + } + export default function() { + return ; + } + `, + + "app/routes/with-nested-links.nested.tsx": js` + import globalCss from '../global.css?url'; + import localCss from '../local.css?url'; + + export function links() { + return [ + // Same links as parent route but with different key order + { + href: globalCss, + rel: "stylesheet", + }, + { + imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", + imageSizes: "9999px", + rel: "preload", + as: "image", + }, + // Unique links for child route + { + rel: "stylesheet", + href: localCss, + }, + { + rel: "preload", + as: "image", + imageSrcSet: "image-700.jpg 700w, image-1400.jpg 1400w", + imageSizes: "9999px", + }, + ]; + } + export default function() { + return

With Nested Links

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='http://github.com/with-nested-links/nested']"); + await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { + state: "attached", + }); + expect( + await page.locator("#nav link[rel='prefetch'][as='style']").count() + ).toBe(2); + expect( + await page.locator("#nav link[rel='prefetch'][as='image']").count() + ).toBe(2); + }); + }); }); - expect( - await page.locator("#nav link[rel='prefetch'][as='style']").count() - ).toBe(2); - expect( - await page.locator("#nav link[rel='prefetch'][as='image']").count() - ).toBe(2); }); }); diff --git a/integration/vite-basename-test.ts b/integration/vite-basename-test.ts index 111a9201e8..af4c3bf53b 100644 --- a/integration/vite-basename-test.ts +++ b/integration/vite-basename-test.ts @@ -70,14 +70,15 @@ async function configFiles({ basename: basename !== "/" ? basename : undefined, }), "vite.config.js": js` - import { reactRouter } from "@react-router/dev/vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default { - ${base !== "/" ? 'base: "' + base + '",' : ""} - ${await viteConfig.server({ port })} - plugins: [reactRouter()] - } - `, + export default async () => ({ + ${base !== "/" ? 'base: "' + base + '",' : ""} + ${await viteConfig.server({ port })} + ${viteConfig.build()} + plugins: [reactRouter()] + }) + `, }; } diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts index 59155b5502..40f81ef097 100644 --- a/integration/vite-build-test.ts +++ b/integration/vite-build-test.ts @@ -10,6 +10,7 @@ import { reactRouterConfig, viteConfig, grep, + viteMajorTemplates, } from "./helpers/vite.js"; let port: number; @@ -20,364 +21,376 @@ const js = String.raw; test.describe("Build", () => { [false, true].forEach((viteEnvironmentApi) => { - test.describe(`viteEnvironmentApi enabled: ${viteEnvironmentApi}`, () => { - test.beforeAll(async () => { - port = await getPort(); - cwd = await createProject( - { - ".env": ` - ENV_VAR_FROM_DOTENV_FILE=true - `, - "react-router.config.ts": reactRouterConfig({ viteEnvironmentApi }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - import mdx from "@mdx-js/rollup"; - - export default defineConfig({ - ${await viteConfig.server({ port })} - build: { - // force emitting asset files instead of inlined as data-url - assetsInlineLimit: 0, - }, - plugins: [ - mdx(), - reactRouter(), - ], - }); - `, - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "react-router"; - - export default function Root() { - return ( - - - - - - -
-

Root

- -
- - - - ); - } - `, - "app/routes/_index.tsx": js` - import { useState, useEffect } from "react"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return { serverOnly1 } - } - - export const action = () => { - console.log(serverOnly2) - return null - } - - export default function() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - return ( - <> -

Index

- {!mounted ?

Loading...

:

Mounted

} - - ); - } - `, - "app/utils.server.ts": js` - export const serverOnly1 = "SERVER_ONLY_1" - export const serverOnly2 = "SERVER_ONLY_2" - `, - "app/routes/resource.ts": js` - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return { serverOnly1 } - } - - export const action = () => { - console.log(serverOnly2) - return null - } - `, - "app/routes/mdx.mdx": js` - import { useEffect, useState } from "react"; - import { useLoaderData } from "react-router"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return { - serverOnly1, - content: "MDX route content from loader", + viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => { + // Vite 5 doesn't support the Environment API + if (templateName === "vite-5-template" && viteEnvironmentApi) { + return; + } + + test.describe(`${templateDisplayName}${ + viteEnvironmentApi ? " with Vite Environment API" : "" + }`, () => { + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject( + { + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=true + `, + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi, + }), + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig({ + ${await viteConfig.server({ port })} + ${viteConfig.build({ + assetsInlineLimit: 0, + })} + plugins: [ + mdx(), + reactRouter(), + ], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+

Root

+ +
+ + + + ); } - } - - export const action = () => { - console.log(serverOnly2) - return null - } - - export function MdxComponent() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - const { content } = useLoaderData(); - const text = content + (mounted - ? ": mounted" - : ": not mounted"); - return
{text}
- } - - ## MDX Route - - - `, - "app/routes/code-split1.tsx": js` - import { CodeSplitComponent } from "../code-split-component"; - - export default function CodeSplit1Route() { - return
; - } - `, - "app/routes/code-split2.tsx": js` - import { CodeSplitComponent } from "../code-split-component"; - - export default function CodeSplit2Route() { - return
; - } - `, - "app/code-split-component.tsx": js` - import classes from "./code-split.module.css"; - - export function CodeSplitComponent() { - return ok - } - `, - "app/code-split.module.css": js` - .test { - background-color: rgb(255, 170, 0); - } - `, - "app/routes/dotenv.tsx": js` - import { useLoaderData } from "react-router"; - - export const loader = () => { - return { - loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', + `, + "app/routes/_index.tsx": js` + import { useState, useEffect } from "react"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return { serverOnly1 } } - } - - export default function DotenvRoute() { - const { loaderContent } = useLoaderData(); - - return
{loaderContent}
; - } - `, - - "app/assets/test.txt": "test", - "app/routes/ssr-only-assets.tsx": js` - import txtUrl from "../assets/test.txt?url"; - import { useLoaderData } from "react-router" - - export const loader: LoaderFunction = () => { - return { txtUrl }; - }; - - export default function SsrOnlyAssetsRoute() { - const loaderData = useLoaderData(); - return ( -
- txtUrl -
- ); - } - `, - - "app/assets/test.css": ".test{color:red}", - "app/routes/ssr-only-css-url-files.tsx": js` - import cssUrl from "../assets/test.css?url"; - import { useLoaderData } from "react-router" - - export const loader: LoaderFunction = () => { - return { cssUrl }; - }; - - export default function SsrOnlyCssUrlFilesRoute() { - const loaderData = useLoaderData(); - return ( -
- cssUrl -
- ); - } - `, - - "app/routes/ssr-code-split.tsx": js` - import { useLoaderData } from "react-router" - - export const loader: LoaderFunction = async () => { - const lib = await import("../ssr-code-split-lib"); - return lib.ssrCodeSplitTest(); - }; - - export default function SsrCodeSplitRoute() { - const loaderData = useLoaderData(); - return ( -
{loaderData}
- ); - } - `, - - "app/ssr-code-split-lib.ts": js` - export function ssrCodeSplitTest() { - return "ssrCodeSplitTest"; - } - `, - }, - viteEnvironmentApi ? "vite-6-template" : "vite-5-template" - ); - - let { stderr, status } = build({ cwd }); - expect( - stderr - .toString() - // This can be removed when this issue is fixed: https://github.com/remix-run/remix/issues/9055 - .replace('Generated an empty chunk: "resource".', "") - .trim() - ).toBeFalsy(); - expect(status).toBe(0); - stop = await reactRouterServe({ cwd, port }); - }); - test.afterAll(() => stop()); - - test("server code is removed from client build", async () => { - expect( - grep(path.join(cwd, "build/client"), /SERVER_ONLY_1/).length - ).toBe(0); - expect( - grep(path.join(cwd, "build/client"), /SERVER_ONLY_2/).length - ).toBe(0); - }); - test("renders matching MDX routes", async ({ page }) => { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + export const action = () => { + console.log(serverOnly2) + return null + } - await page.goto(`http://localhost:${port}/mdx`, { - waitUntil: "networkidle", - }); - await expect(page.locator("[data-mdx-route]")).toHaveText( - "MDX route content from loader: mounted" - ); - expect(pageErrors).toEqual([]); - }); + export default function() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( + <> +

Index

+ {!mounted ?

Loading...

:

Mounted

} + + ); + } + `, + "app/utils.server.ts": js` + export const serverOnly1 = "SERVER_ONLY_1" + export const serverOnly2 = "SERVER_ONLY_2" + `, + "app/routes/resource.ts": js` + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return { serverOnly1 } + } - test("emits SSR-only assets to the client assets directory", async ({ - page, - }) => { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + export const action = () => { + console.log(serverOnly2) + return null + } + `, + "app/routes/mdx.mdx": js` + import { useEffect, useState } from "react"; + import { useLoaderData } from "react-router"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return { + serverOnly1, + content: "MDX route content from loader", + } + } - await page.goto(`http://localhost:${port}/ssr-only-assets`, { - waitUntil: "networkidle", - }); + export const action = () => { + console.log(serverOnly2) + return null + } - await page.getByRole("link", { name: "txtUrl" }).click(); - await page.waitForURL("**/assets/test-*.txt"); - await expect(page.getByText("test")).toBeVisible(); - expect(pageErrors).toEqual([]); - }); + export function MdxComponent() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + const { content } = useLoaderData(); + const text = content + (mounted + ? ": mounted" + : ": not mounted"); + return
{text}
+ } - test("emits SSR-only .css?url files to the client assets directory", async ({ - page, - }) => { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + ## MDX Route - await page.goto(`http://localhost:${port}/ssr-only-css-url-files`, { - waitUntil: "networkidle", - }); + + `, + "app/routes/code-split1.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; - await page.getByRole("link", { name: "cssUrl" }).click(); - await page.waitForURL("**/assets/test-*.css"); - await expect(page.getByText(".test{")).toBeVisible(); - expect(pageErrors).toEqual([]); - }); + export default function CodeSplit1Route() { + return
; + } + `, + "app/routes/code-split2.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; + + export default function CodeSplit2Route() { + return
; + } + `, + "app/code-split-component.tsx": js` + import classes from "./code-split.module.css"; + + export function CodeSplitComponent() { + return ok + } + `, + "app/code-split.module.css": js` + .test { + background-color: rgb(255, 170, 0); + } + `, + "app/routes/dotenv.tsx": js` + import { useLoaderData } from "react-router"; + + export const loader = () => { + return { + loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', + } + } - test("supports code-split JS from SSR build", async ({ page }) => { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); - await page.goto(`http://localhost:${port}/ssr-code-split`, { - waitUntil: "networkidle", + return
{loaderContent}
; + } + `, + + "app/assets/test.txt": "test", + "app/routes/ssr-only-assets.tsx": js` + import txtUrl from "../assets/test.txt?url"; + import { useLoaderData } from "react-router" + + export const loader: LoaderFunction = () => { + return { txtUrl }; + }; + + export default function SsrOnlyAssetsRoute() { + const loaderData = useLoaderData(); + return ( +
+ txtUrl +
+ ); + } + `, + + "app/assets/test.css": ".test{color:red}", + "app/routes/ssr-only-css-url-files.tsx": js` + import cssUrl from "../assets/test.css?url"; + import { useLoaderData } from "react-router" + + export const loader: LoaderFunction = () => { + return { cssUrl }; + }; + + export default function SsrOnlyCssUrlFilesRoute() { + const loaderData = useLoaderData(); + return ( +
+ cssUrl +
+ ); + } + `, + + "app/routes/ssr-code-split.tsx": js` + import { useLoaderData } from "react-router" + + export const loader: LoaderFunction = async () => { + const lib = await import("../ssr-code-split-lib"); + return lib.ssrCodeSplitTest(); + }; + + export default function SsrCodeSplitRoute() { + const loaderData = useLoaderData(); + return ( +
{loaderData}
+ ); + } + `, + + "app/ssr-code-split-lib.ts": js` + export function ssrCodeSplitTest() { + return "ssrCodeSplitTest"; + } + `, + }, + templateName + ); + + let { stderr, status } = build({ cwd }); + expect( + stderr + .toString() + // This can be removed when this issue is fixed: https://github.com/remix-run/remix/issues/9055 + .replace('Generated an empty chunk: "resource".', "") + .trim() + ).toBeFalsy(); + expect(status).toBe(0); + stop = await reactRouterServe({ cwd, port }); + }); + test.afterAll(() => stop()); + + test("server code is removed from client build", async () => { + expect( + grep(path.join(cwd, "build/client"), /SERVER_ONLY_1/).length + ).toBe(0); + expect( + grep(path.join(cwd, "build/client"), /SERVER_ONLY_2/).length + ).toBe(0); }); - await expect(page.locator("[data-ssr-code-split]")).toHaveText( - "ssrCodeSplitTest" - ); - expect(pageErrors).toEqual([]); - }); + test("renders matching MDX routes", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/mdx`, { + waitUntil: "networkidle", + }); + await expect(page.locator("[data-mdx-route]")).toHaveText( + "MDX route content from loader: mounted" + ); + expect(pageErrors).toEqual([]); + }); - test("removes assets (other than code-split JS) and CSS files from SSR build", async () => { - let assetFiles = glob.sync("build/server/assets/**/*", { cwd }); - let [asset, ...rest] = assetFiles; - expect(rest).toEqual([]); // Provide more useful test output if this fails - expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); - }); + test("emits SSR-only assets to the client assets directory", async ({ + page, + }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - test("supports code-split CSS", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + await page.goto(`http://localhost:${port}/ssr-only-assets`, { + waitUntil: "networkidle", + }); - await page.goto(`http://localhost:${port}/code-split1`, { - waitUntil: "networkidle", + await page.getByRole("link", { name: "txtUrl" }).click(); + await page.waitForURL("**/assets/test-*.txt"); + await expect(page.getByText("test")).toBeVisible(); + expect(pageErrors).toEqual([]); }); - expect( - await page - .locator("#code-split1 span") - .evaluate((e) => window.getComputedStyle(e).backgroundColor) - ).toBe("rgb(255, 170, 0)"); - - await page.goto(`http://localhost:${port}/code-split2`, { - waitUntil: "networkidle", + + test("emits SSR-only .css?url files to the client assets directory", async ({ + page, + }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/ssr-only-css-url-files`, { + waitUntil: "networkidle", + }); + + await page.getByRole("link", { name: "cssUrl" }).click(); + await page.waitForURL("**/assets/test-*.css"); + await expect(page.getByText(".test{")).toBeVisible(); + expect(pageErrors).toEqual([]); }); - expect( - await page - .locator("#code-split2 span") - .evaluate((e) => window.getComputedStyle(e).backgroundColor) - ).toBe("rgb(255, 170, 0)"); - expect(pageErrors).toEqual([]); - }); + test("supports code-split JS from SSR build", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/ssr-code-split`, { + waitUntil: "networkidle", + }); - test("doesn't load .env file", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + await expect(page.locator("[data-ssr-code-split]")).toHaveText( + "ssrCodeSplitTest" + ); + expect(pageErrors).toEqual([]); + }); + + test("removes assets (other than code-split JS) and CSS files from SSR build", async () => { + let assetFiles = glob.sync("build/server/assets/**/*", { cwd }); + let [asset, ...rest] = assetFiles; + expect(rest).toEqual([]); // Provide more useful test output if this fails + expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); + }); - await page.goto(`http://localhost:${port}/dotenv`, { - waitUntil: "networkidle", + test("supports code-split CSS", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/code-split1`, { + waitUntil: "networkidle", + }); + expect( + await page + .locator("#code-split1 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + await page.goto(`http://localhost:${port}/code-split2`, { + waitUntil: "networkidle", + }); + expect( + await page + .locator("#code-split2 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + expect(pageErrors).toEqual([]); }); - expect(pageErrors).toEqual([]); - let loaderContent = page.locator("[data-dotenv-route-loader-content]"); - await expect(loaderContent).toHaveText( - ".env file was NOT loaded, which is a good thing" - ); + test("doesn't load .env file", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - expect(pageErrors).toEqual([]); + await page.goto(`http://localhost:${port}/dotenv`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let loaderContent = page.locator( + "[data-dotenv-route-loader-content]" + ); + await expect(loaderContent).toHaveText( + ".env file was NOT loaded, which is a good thing" + ); + + expect(pageErrors).toEqual([]); + }); }); }); }); diff --git a/integration/vite-css-test.ts b/integration/vite-css-test.ts index 257f04286c..866ec4bdfd 100644 --- a/integration/vite-css-test.ts +++ b/integration/vite-css-test.ts @@ -162,8 +162,9 @@ const VITE_CONFIG = async ({ import { reactRouter } from "@react-router/dev/vite"; import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; - export default { + export default async () => ({ ${await viteConfig.server({ port })} + ${viteConfig.build()} ${base ? `base: "${base}",` : ""} plugins: [ reactRouter(), @@ -171,7 +172,7 @@ const VITE_CONFIG = async ({ emitCssInSsr: true, }), ], - } + }); `; test.describe("Vite CSS", () => { @@ -260,11 +261,14 @@ test.describe("Vite CSS", () => { test.beforeAll(async () => { port = await getPort(); - cwd = await createProject({ - "vite.config.ts": await VITE_CONFIG({ port }), - "server.mjs": EXPRESS_SERVER({ port }), - ...files, - }); + cwd = await createProject( + { + "vite.config.ts": await VITE_CONFIG({ port }), + "server.mjs": EXPRESS_SERVER({ port }), + ...files, + }, + templateName + ); stop = await customDev({ cwd, port }); }); test.afterAll(() => stop()); @@ -292,10 +296,13 @@ test.describe("Vite CSS", () => { test.beforeAll(async () => { port = await getPort(); - cwd = await createProject({ - "vite.config.ts": await VITE_CONFIG({ port }), - ...files, - }); + cwd = await createProject( + { + "vite.config.ts": await VITE_CONFIG({ port }), + ...files, + }, + templateName + ); let edit = createEditor(cwd); await edit("package.json", (contents) => diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 12ea017b71..6128229996 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -5,7 +5,13 @@ import { expect } from "@playwright/test"; import { normalizePath } from "vite"; import dedent from "dedent"; -import { build, test, createProject } from "./helpers/vite.js"; +import { + build, + test, + createProject, + viteMajorTemplates, + viteConfig, +} from "./helpers/vite.js"; const js = String.raw; @@ -131,124 +137,121 @@ const files = { ], } `), - "vite.config.ts": dedent(js` - import { reactRouter } from "@react-router/dev/vite"; - - export default { - build: { - assetsDir: "custom-assets-dir", - }, - plugins: [reactRouter()], - } - `), + "vite.config.ts": await viteConfig.basic({ + assetsDir: "custom-assets-dir", + }), }; -test("Vite / presets", async () => { - let cwd = await createProject(files); - let { status, stderr } = build({ cwd }); - expect(stderr.toString()).toBeFalsy(); - expect(status).toBe(0); - - function pathStartsWithCwd(pathname: string) { - return normalizePath(pathname).startsWith(normalizePath(cwd)); - } - - function relativeToCwd(pathname: string) { - return normalizePath(path.relative(cwd, pathname)); - } - - let buildEndArgsMeta: any = await import( - URL.pathToFileURL(path.join(cwd, "BUILD_END_META.js")).href - ); - - let { reactRouterConfig } = buildEndArgsMeta; - - // Smoke test Vite config - expect(buildEndArgsMeta.assetsDir).toBe("custom-assets-dir"); - - // Before rewriting to relative paths, assert that paths are absolute within cwd - expect(pathStartsWithCwd(reactRouterConfig.buildDirectory)).toBe(true); - - // Rewrite path args to be relative and normalized for snapshot test - reactRouterConfig.buildDirectory = relativeToCwd( - reactRouterConfig.buildDirectory - ); - - // Ensure preset configs are merged in correct order, resulting in the correct build directory - expect(reactRouterConfig.buildDirectory).toBe("build"); - - // Ensure preset config takes lower precedence than user config - expect(reactRouterConfig.serverModuleFormat).toBe("esm"); - - // Ensure `reactRouterConfig` is called with a frozen user config - expect( - JSON.parse( - await fs.readFile( - path.join(cwd, "PRESET_REACT_ROUTER_CONFIG_META.json"), - "utf-8" - ) - ) - ).toEqual({ - reactRouterUserConfigFrozen: true, - }); +test.describe("Vite / presets", async () => { + viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => { + test(templateDisplayName, async () => { + let cwd = await createProject(files, templateName); + let { status, stderr } = build({ cwd }); + expect(stderr.toString()).toBeFalsy(); + expect(status).toBe(0); - // Ensure `reactRouterConfigResolved` is called with a frozen config - expect( - JSON.parse( - await fs.readFile( - path.join(cwd, "PRESET_REACT_ROUTER_CONFIG_RESOLVED_META.json"), - "utf-8" - ) - ) - ).toEqual({ - reactRouterUserConfigFrozen: true, - }); + function pathStartsWithCwd(pathname: string) { + return normalizePath(pathname).startsWith(normalizePath(cwd)); + } + + function relativeToCwd(pathname: string) { + return normalizePath(path.relative(cwd, pathname)); + } + + let buildEndArgsMeta: any = await import( + URL.pathToFileURL(path.join(cwd, "BUILD_END_META.js")).href + ); + + let { reactRouterConfig } = buildEndArgsMeta; + + // Smoke test Vite config + expect(buildEndArgsMeta.assetsDir).toBe("custom-assets-dir"); + + // Before rewriting to relative paths, assert that paths are absolute within cwd + expect(pathStartsWithCwd(reactRouterConfig.buildDirectory)).toBe(true); + + // Rewrite path args to be relative and normalized for snapshot test + reactRouterConfig.buildDirectory = relativeToCwd( + reactRouterConfig.buildDirectory + ); + + // Ensure preset configs are merged in correct order, resulting in the correct build directory + expect(reactRouterConfig.buildDirectory).toBe("build"); + + // Ensure preset config takes lower precedence than user config + expect(reactRouterConfig.serverModuleFormat).toBe("esm"); + + // Ensure `reactRouterConfig` is called with a frozen user config + expect( + JSON.parse( + await fs.readFile( + path.join(cwd, "PRESET_REACT_ROUTER_CONFIG_META.json"), + "utf-8" + ) + ) + ).toEqual({ + reactRouterUserConfigFrozen: true, + }); + + // Ensure `reactRouterConfigResolved` is called with a frozen config + expect( + JSON.parse( + await fs.readFile( + path.join(cwd, "PRESET_REACT_ROUTER_CONFIG_RESOLVED_META.json"), + "utf-8" + ) + ) + ).toEqual({ + reactRouterUserConfigFrozen: true, + }); + + // Snapshot the buildEnd args keys + expect(buildEndArgsMeta.keys).toEqual([ + "buildManifest", + "reactRouterConfig", + "viteConfig", + ]); + + // Smoke test the resolved config + expect(Object.keys(reactRouterConfig)).toEqual([ + "appDirectory", + "basename", + "buildDirectory", + "buildEnd", + "future", + "prerender", + "routes", + "serverBuildFile", + "serverBundles", + "serverModuleFormat", + "ssr", + ]); - // Snapshot the buildEnd args keys - expect(buildEndArgsMeta.keys).toEqual([ - "buildManifest", - "reactRouterConfig", - "viteConfig", - ]); - - // Smoke test the resolved config - expect(Object.keys(reactRouterConfig)).toEqual([ - "appDirectory", - "basename", - "buildDirectory", - "buildEnd", - "future", - "prerender", - "routes", - "serverBuildFile", - "serverBundles", - "serverModuleFormat", - "ssr", - ]); - - // Ensure we get a valid build manifest - expect(buildEndArgsMeta.buildManifest).toEqual({ - routeIdToServerBundleId: { - "routes/_index": "preset-server-bundle-id", - }, - routes: { - root: { - file: "app/root.tsx", - id: "root", - path: "", - }, - "routes/_index": { - file: "app/routes/_index.tsx", - id: "routes/_index", - index: true, - parentId: "root", - }, - }, - serverBundles: { - "preset-server-bundle-id": { - file: "build/server/preset-server-bundle-id/index.js", - id: "preset-server-bundle-id", - }, - }, + // Ensure we get a valid build manifest + expect(buildEndArgsMeta.buildManifest).toEqual({ + routeIdToServerBundleId: { + "routes/_index": "preset-server-bundle-id", + }, + routes: { + root: { + file: "app/root.tsx", + id: "root", + path: "", + }, + "routes/_index": { + file: "app/routes/_index.tsx", + id: "routes/_index", + index: true, + parentId: "root", + }, + }, + serverBundles: { + "preset-server-bundle-id": { + file: "build/server/preset-server-bundle-id/index.js", + id: "preset-server-bundle-id", + }, + }, + }); + }); }); }); diff --git a/integration/vite-route-exports-modified-offscreen-test.ts b/integration/vite-route-exports-modified-offscreen-test.ts index 6ee7562998..56d5d50894 100644 --- a/integration/vite-route-exports-modified-offscreen-test.ts +++ b/integration/vite-route-exports-modified-offscreen-test.ts @@ -76,6 +76,8 @@ test.describe(async () => { originalContents = contents; return contents.replace(/export const loader.*/, ""); }); + // Give the server time to pick the manifest change + await new Promise((resolve) => setTimeout(resolve, 200)); // After browser reload, client should be aware that there's no loader on the other route if (browserName === "webkit") { @@ -83,12 +85,15 @@ test.describe(async () => { // Otherwise browser doesn't seem to fetch new manifest probably due to caching. page = await context.newPage(); } - await page.goto(`http://localhost:${port}`, { waitUntil: "networkidle" }); - await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); - await page.getByRole("link", { name: "/other" }).click(); - await expect(page.locator("[data-loader-data]")).toHaveText( - "loaderData = null" - ); + // In case the earlier wait wasn't enough, let the test try again + await expect(async () => { + await page.goto(`http://localhost:${port}`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); + await page.getByRole("link", { name: "/other" }).click(); + await expect(page.locator("[data-loader-data]")).toHaveText( + "loaderData = null" + ); + }).toPass(); expect(pageErrors).toEqual([]); // Revert route to original state to check HMR works and to ensure the diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 543e03ec7b..66c24acfd9 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -3408,29 +3408,46 @@ export async function getEnvironmentOptionsResolvers( }: { viteUserConfig: Vite.UserConfig; }): EnvironmentOptions { + // This is a workaround for type errors when running in vite-ecosystem-ci + // against rolldown-vite since "preserveEntrySignatures" is not yet + // supported. We're doing this instead of using `ts-ignore` so we're still + // type checking against regular Vite build options. Once it's supported, + // this custom type can be removed and the build config can be inlined. + type RollupOptionsWithPreserveEntrySignatures = + Vite.BuildOptions["rollupOptions"] extends { + preserveEntrySignatures: any; + } + ? Vite.BuildOptions["rollupOptions"] + : Vite.BuildOptions["rollupOptions"] & { + // We hard-code the one value we're using. If it's not valid in + // Rollup, the build will fail. + preserveEntrySignatures: "exports-only"; + }; + const rollupOptions: RollupOptionsWithPreserveEntrySignatures = { + preserveEntrySignatures: "exports-only", + // Silence Rollup "use client" warnings + // Adapted from https://github.com/vitejs/vite-plugin-react/pull/144 + onwarn(warning, defaultHandler) { + if ( + warning.code === "MODULE_LEVEL_DIRECTIVE" && + warning.message.includes("use client") + ) { + return; + } + let userHandler = viteUserConfig.build?.rollupOptions?.onwarn; + if (userHandler) { + userHandler(warning, defaultHandler); + } else { + defaultHandler(warning); + } + }, + }; + return { build: { cssMinify: viteUserConfig.build?.cssMinify ?? true, manifest: true, // The manifest is enabled for all builds to detect SSR-only assets - rollupOptions: { - preserveEntrySignatures: "exports-only", - // Silence Rollup "use client" warnings - // Adapted from https://github.com/vitejs/vite-plugin-react/pull/144 - onwarn(warning, defaultHandler) { - if ( - warning.code === "MODULE_LEVEL_DIRECTIVE" && - warning.message.includes("use client") - ) { - return; - } - let userHandler = viteUserConfig.build?.rollupOptions?.onwarn; - if (userHandler) { - userHandler(warning, defaultHandler); - } else { - defaultHandler(warning); - } - }, - }, + rollupOptions, }, }; } diff --git a/playground/framework-rolldown-vite/vite.config.ts b/playground/framework-rolldown-vite/vite.config.ts index fac933f23c..bc98cf6730 100644 --- a/playground/framework-rolldown-vite/vite.config.ts +++ b/playground/framework-rolldown-vite/vite.config.ts @@ -1,11 +1,19 @@ import { reactRouter } from "@react-router/dev/vite"; -import { defineConfig } from "vite"; +import { defineConfig, type UserConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; -export default defineConfig({ - plugins: [ - // @ts-expect-error `dev` depends on Vite 6, Plugin type is mismatched. - reactRouter(), - tsconfigPaths(), - ], +export default defineConfig(({ isSsrBuild }) => { + const config: UserConfig = { + plugins: [ + // @ts-expect-error `dev` depends on Vite 6, Plugin type is mismatched. + reactRouter(), + tsconfigPaths(), + ], + build: { + // Built-in minifier is still experimental + minify: isSsrBuild ? false : "esbuild", + }, + }; + + return config; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 771bb77ae1..a06c5467fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -585,6 +585,76 @@ importers: specifier: ^4.2.0 version: 4.2.0(@cloudflare/workers-types@4.20250317.0) + integration/helpers/vite-rolldown-template: + dependencies: + '@react-router/express': + specifier: workspace:* + version: link:../../../packages/react-router-express + '@react-router/node': + specifier: workspace:* + version: link:../../../packages/react-router-node + '@react-router/serve': + specifier: workspace:* + version: link:../../../packages/react-router-serve + '@vanilla-extract/css': + specifier: ^1.10.0 + version: 1.14.2 + '@vanilla-extract/vite-plugin': + specifier: ^3.9.2 + version: 3.9.5(@types/node@20.11.30)(lightningcss@1.29.3)(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0)) + express: + specifier: ^4.19.2 + version: 4.19.2 + isbot: + specifier: ^5.1.11 + version: 5.1.11 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + react-router: + specifier: workspace:* + version: link:../../../packages/react-router + serialize-javascript: + specifier: ^6.0.1 + version: 6.0.2 + devDependencies: + '@react-router/dev': + specifier: workspace:* + version: link:../../../packages/react-router-dev + '@react-router/fs-routes': + specifier: workspace:* + version: link:../../../packages/react-router-fs-routes + '@react-router/remix-routes-option-adapter': + specifier: workspace:* + version: link:../../../packages/react-router-remix-routes-option-adapter + '@types/react': + specifier: ^18.2.18 + version: 18.2.18 + '@types/react-dom': + specifier: ^18.2.7 + version: 18.2.7 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + eslint: + specifier: ^8.38.0 + version: 8.57.0 + typescript: + specifier: ^5.1.6 + version: 5.4.5 + vite: + specifier: npm:rolldown-vite@6.3.0-beta.5 + version: rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0) + vite-env-only: + specifier: ^3.0.1 + version: 3.0.1(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0)) + vite-tsconfig-paths: + specifier: ^4.2.1 + version: 4.3.2(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0))(typescript@5.4.5) + packages/create-react-router: dependencies: '@remix-run/web-fetch': @@ -3517,61 +3587,121 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-aq6Y9OQl05bYUnzM4a7ZGF3+Du7cdrw3Ala1eCnvNqxgi2ksXKN+LHvgeaWDlyfLgX0jVQFZre4+kzgLSHEMog==} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-tA3K/yj2MDIKmpMjldEKkS/1k8o8MXIm+bMdLahZmFVRE7ODfQRe3aUaaxTm7wvHG8GKgE4DcqMJTwDeCqAt/g==} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-GRxENhaf92Blo7TZz8C8vBFSt4pCRWDP45ElGATItWqzyM+ILtzNjkE5Wj1OyWPe7y0oWxps6YMxVxEdb3/BJQ==} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-Ps9e395Gmv3nR+WmOLGnN23Qc5R7GZ619QMnrsZZnrNjqts4pf2DAGoPnTY/dCT/z+rfcN3ku35hWh3HsI9XGA==} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-3uibg1KMHT7c149YupfXICxKoO6K7q3MaMpvOdxUjTY9mY3+v96eHTfnq+dd6qD16ppKSVij7FfpEC+sCVDRmg==} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-/RKVSZGQyFpDWI2ksNV7/n2M1bbFvIoS4QvcETU+sMnDfhZQB6vP00dHMFsJS9J+y05XbsMnEgHslrLywFu4Ww==} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-oDFqE2fWx2sj0E9doRUmYxi5TKc9/vmD49NP0dUN578LQO8nJBwqOMvE8bM3a/T3or4g1mlJt2ebzGiKGzjHSw==} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-J6PeOqrX2QttacikU/CcIG2nlsnR9gDTcUQbwEbS1G/DaPrYEHXujiI4YY5Hmd+Sr1IYXI9i3z/RfzRI9XmcpQ==} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-0Weogg1WiFNkmwznM4YS4AmDa55miGstb/I4zKquIsv1kSBLBkxboifgWTCPUnGFK7Wy1u/beRnxCY7UVL1oPw==} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-lMUOKYcdDxpZMvkMbznjkqikPnvo3UIpdEfzEMp2/rOlYyC/2p3Trg3kGjhF4lbfRLbbuPEjLepGf67ot0I8oQ==} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-LwEN10APipzjxAHSreVTEnUAHRz3sq4/UR3IVD/AguV0s6yAbVAsIrvIoxXHKoci+RUlyY5FXICYZQKli8iU5w==} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-ydsgeyhu3/AvB+I1/+uQ1+PSEQRmftkvJ1ewoXB0oJTozAKN6Ywx8jnmV8jA1g/IuMDzepR6/ixF0hbyYinWWQ==} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-tgE2J4BAQfI0rfoPzS4r1LEHSNxdNSM8l1Ab5InnzE4dXzVw92BVQ/FLFE6L+nWy81O7uwd7yz0Jo+qByOPCXg==} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-prSpmuIoS6M1KLRd2Fzpz9n6K6K8g8/F5bN15iEpjRZCkCOI24+bVX6fDKbI0frstIMzFVvbGSxmHxt0pyphEA==} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-m78svPie3D5PIBxmexztDVHjrnHO5t6h3Lwtl6sqdrio1zhGYMY9FcPcaZZ40mXXWKHFoPmbueBZZLdttvOEIQ==} + cpu: [x64] + os: [linux] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-kRFr1jOfL4L627d1Bw/EPst3A2BwP+DV6CH/Myxl88DFzAeOAfQ04hFfCm8lBcRxzfrJNcFAMNrdIKgdUd7ddQ==} engines: {node: '>=14.21.3'} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-XbOcOWmdioNZ3hHBb5j96Y9S9pGyTeFZWR5ovMZggA9L7mWft2pMrbx4p5zUy2dCps3l1jaFQCjKuBXpwoCZug==} + engines: {node: '>=14.21.3'} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-1l+vls3mjcKOxsrnwcwG1fX8/pL7URuZ+d+7WvKaXXIq3Id6HSdtCYuBwkUg3Bdm0mLDk7Qyv1QG3BwTcFahGQ==} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-lnZ/wrat6UMWGbS9w5AUEH8WkPBA4EUSYp8cxlspdU16dISeD/KGpF2d0hS6Oa6ftbgZZrRLMEnQRiD8OupPsg==} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-YJxvaPtH4sl5reLZCvNuqFHCgdsIRGG77LET+xng9CEWGaA1Epx2qcbeAAX8czU82tYrorx5Taxioo3GqvF53w==} cpu: [ia32] os: [win32] + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-F0N/6kAnCl9dOgqR09T60UjQSxKvRtlbImhiYxIdKBFxgYDDGsh8XzlSbMRUVQmMtNwKC8xi+i+SnamSqY6q8Q==} + cpu: [ia32] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-OZok4v+44zYlSqo5pVyt5xPgruYcaPig9T0ieOh+O7f3BWqlkLI3ZFalznq2zFp4mJS7GtrqOAm6h7sgd+LTOw==} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-T3qKMkSVemlVLLd5V7dCXnjt4Zda1UnUi45AQnmxIf3jH0/VP0J4aYAJiEEaRbhMoHc82j01+6MuZFZUVMeqng==} + cpu: [x64] + os: [win32] + '@rollup/pluginutils@5.1.0': resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} engines: {node: '>=14.0.0'} @@ -7821,6 +7951,46 @@ packages: yaml: optional: true + rolldown-vite@6.3.0-beta.5: + resolution: {integrity: sha512-/seCUlTV3pHNn0Y8qveGmHMNYxH/Z9xc65Ov0uaA/HtThaMZNTacWsMyDG4SA+S/c1RdpWIe85E5NeOmhywrGg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: '*' + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + rolldown@1.0.0-beta.7-commit.7452fa0: resolution: {integrity: sha512-6/poOMpWJUy+MEd7qt6/f5lOOepR7vUXtMuK+J494yVA6jtkyXlCScvLVytpo13AKx+IhW/wt6qpCaZdFasd0g==} hasBin: true @@ -7830,6 +8000,15 @@ packages: '@oxc-project/runtime': optional: true + rolldown@1.0.0-beta.7-commit.e117288: + resolution: {integrity: sha512-3pjhtA9BV/q9cNdcz75ehvie3lgFfJZfzIT8A7aZJPvFCaWTj5AUAlcExXRWO/CIMMZ/49Y1x3MTwRC/Q/LuAw==} + hasBin: true + peerDependencies: + '@oxc-project/runtime': 0.61.2 + peerDependenciesMeta: + '@oxc-project/runtime': + optional: true + rollup@4.24.0: resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -11194,41 +11373,79 @@ snapshots: '@rolldown/binding-darwin-arm64@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.7-commit.7452fa0': dependencies: '@napi-rs/wasm-runtime': 0.2.7 optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.7-commit.e117288': + dependencies: + '@napi-rs/wasm-runtime': 0.2.7 + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.7-commit.e117288': + optional: true + '@rollup/pluginutils@5.1.0(rollup@4.34.8)': dependencies: '@types/estree': 1.0.6 @@ -11954,6 +12171,24 @@ snapshots: '@vanilla-extract/private@1.0.4': {} + '@vanilla-extract/vite-plugin@3.9.5(@types/node@20.11.30)(lightningcss@1.29.3)(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0))': + dependencies: + '@vanilla-extract/integration': 6.5.0(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0) + outdent: 0.8.0 + postcss: 8.4.49 + postcss-load-config: 4.0.2(postcss@8.4.49) + vite: rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + - ts-node + '@vanilla-extract/vite-plugin@3.9.5(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0)(vite@5.1.3(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0))': dependencies: '@vanilla-extract/integration': 6.5.0(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0) @@ -16627,6 +16862,24 @@ snapshots: transitivePeerDependencies: - typescript + rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0): + dependencies: + '@oxc-project/runtime': 0.61.2 + lightningcss: 1.29.3 + picomatch: 4.0.2 + postcss: 8.5.3 + rolldown: 1.0.0-beta.7-commit.e117288(@oxc-project/runtime@0.61.2)(typescript@5.4.5) + tinyglobby: 0.2.12 + optionalDependencies: + '@types/node': 20.11.30 + esbuild: 0.25.0 + fsevents: 2.3.3 + jiti: 1.21.0 + tsx: 4.19.3 + yaml: 2.6.0 + transitivePeerDependencies: + - typescript + rolldown@1.0.0-beta.7-commit.7452fa0(@oxc-project/runtime@0.61.2)(typescript@5.4.5): dependencies: '@oxc-project/types': 0.61.2 @@ -16649,6 +16902,28 @@ snapshots: transitivePeerDependencies: - typescript + rolldown@1.0.0-beta.7-commit.e117288(@oxc-project/runtime@0.61.2)(typescript@5.4.5): + dependencies: + '@oxc-project/types': 0.61.2 + '@valibot/to-json-schema': 1.0.0(valibot@1.0.0(typescript@5.4.5)) + valibot: 1.0.0(typescript@5.4.5) + optionalDependencies: + '@oxc-project/runtime': 0.61.2 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-darwin-x64': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.7-commit.e117288 + transitivePeerDependencies: + - typescript + rollup@4.24.0: dependencies: '@types/estree': 1.0.6 @@ -17648,6 +17923,19 @@ snapshots: unist-util-stringify-position: 4.0.0 vfile-message: 4.0.2 + vite-env-only@3.0.1(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0)): + dependencies: + '@babel/core': 7.24.3 + '@babel/generator': 7.24.1 + '@babel/parser': 7.24.1 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + babel-dead-code-elimination: 1.0.6 + micromatch: 4.0.5 + vite: rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0) + transitivePeerDependencies: + - supports-color + vite-env-only@3.0.1(vite@5.1.3(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0)): dependencies: '@babel/core': 7.24.3 @@ -17723,6 +18011,17 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@4.3.2(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0))(typescript@5.4.5): + dependencies: + debug: 4.3.7 + globrex: 0.1.2 + tsconfck: 3.0.3(typescript@5.4.5) + optionalDependencies: + vite: rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0) + transitivePeerDependencies: + - supports-color + - typescript + vite-tsconfig-paths@4.3.2(typescript@5.4.5)(vite@5.1.3(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0)): dependencies: debug: 4.3.7 From 7bc242270427552043aee36048434eb56aeba38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Fri, 11 Apr 2025 16:05:10 +0200 Subject: [PATCH 16/25] chore(react-router): remove `@types/cookie` dependency (#13400) --- packages/react-router/package.json | 1 - pnpm-lock.yaml | 8 -------- 2 files changed, 9 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 167debc207..17b0370f30 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -81,7 +81,6 @@ } }, "dependencies": { - "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a06c5467fe..ad1676be74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -733,9 +733,6 @@ importers: packages/react-router: dependencies: - '@types/cookie': - specifier: ^0.6.0 - version: 0.6.0 cookie: specifier: ^1.0.1 version: 1.0.1 @@ -3986,9 +3983,6 @@ packages: '@types/cookie@0.4.1': resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -11694,8 +11688,6 @@ snapshots: '@types/cookie@0.4.1': {} - '@types/cookie@0.6.0': {} - '@types/cookiejar@2.1.5': {} '@types/cross-spawn@6.0.6': From cd5681bd2fd66b8b9958deb1c1fb6bdb9af08366 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 14 Apr 2025 16:40:20 -0400 Subject: [PATCH 17/25] Slight refactor of fetchAndDecode for RSC (#13409) --- .../react-router/lib/dom/ssr/single-fetch.tsx | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 8c79d276d7..a1cd20a27f 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -154,7 +154,7 @@ type GetRouteInfoFunction = (routeId: string) => { }; type FetchAndDecodeFunction = ( - request: Request, + args: DataStrategyFunctionArgs, basename: string | undefined, targetRoutes?: string[] ) => Promise<{ status: number; data: DecodedSingleFetchResults }>; @@ -233,12 +233,7 @@ export function getSingleFetchDataStrategyImpl( // Fetcher loads are singular calls to one loader if (fetcherKey) { - return singleFetchLoaderFetcherStrategy( - request, - matches, - fetchAndDecode, - basename - ); + return singleFetchLoaderFetcherStrategy(args, fetchAndDecode, basename); } // Navigational loads are more complex... @@ -256,16 +251,16 @@ export function getSingleFetchDataStrategyImpl( // Actions are simple since they're singular calls to the server for both // navigations and fetchers) async function singleFetchActionStrategy( - { request, matches }: DataStrategyFunctionArgs, + args: DataStrategyFunctionArgs, fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { - let actionMatch = matches.find((m) => m.unstable_shouldCallHandler()); + let actionMatch = args.matches.find((m) => m.unstable_shouldCallHandler()); invariant(actionMatch, "No action match found"); let actionStatus: number | undefined = undefined; let result = await actionMatch.resolve(async (handler) => { let result = await handler(async () => { - let { data, status } = await fetchAndDecode(request, basename, [ + let { data, status } = await fetchAndDecode(args, basename, [ actionMatch!.route.id, ]); actionStatus = status; @@ -290,12 +285,14 @@ async function singleFetchActionStrategy( // We want to opt-out of Single Fetch when we aren't in SSR mode async function nonSsrStrategy( - { request, matches }: DataStrategyFunctionArgs, + args: DataStrategyFunctionArgs, getRouteInfo: GetRouteInfoFunction, fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { - let matchesToLoad = matches.filter((m) => m.unstable_shouldCallHandler()); + let matchesToLoad = args.matches.filter((m) => + m.unstable_shouldCallHandler() + ); let results: Record = {}; await Promise.all( matchesToLoad.map((m) => @@ -308,9 +305,7 @@ async function nonSsrStrategy( let routeId = m.route.id; let result = hasClientLoader ? await handler(async () => { - let { data } = await fetchAndDecode(request, basename, [ - routeId, - ]); + let { data } = await fetchAndDecode(args, basename, [routeId]); return unwrapSingleFetchResult(data, routeId); }) : await handler(); @@ -327,7 +322,7 @@ async function nonSsrStrategy( // Loaders are trickier since we only want to hit the server once, so we // create a singular promise for all server-loader routes to latch onto. async function singleFetchLoaderNavigationStrategy( - { request, matches }: DataStrategyFunctionArgs, + args: DataStrategyFunctionArgs, router: DataRouter, getRouteInfo: GetRouteInfoFunction, fetchAndDecode: FetchAndDecodeFunction, @@ -341,7 +336,7 @@ async function singleFetchLoaderNavigationStrategy( let foundOptOutRoute = false; // Deferreds per-route so we can be sure they've all loaded via `match.resolve()` - let routeDfds = matches.map(() => createDeferred()); + let routeDfds = args.matches.map(() => createDeferred()); // Deferred we'll use for the singleular call to the server let singleFetchDfd = createDeferred(); @@ -350,7 +345,7 @@ async function singleFetchLoaderNavigationStrategy( let results: Record = {}; let resolvePromise = Promise.all( - matches.map(async (m, i) => + args.matches.map(async (m, i) => m.resolve(async (handler) => { routeDfds[i].resolve(); let routeId = m.route.id; @@ -380,7 +375,7 @@ async function singleFetchLoaderNavigationStrategy( } try { let result = await handler(async () => { - let { data } = await fetchAndDecode(request, basename, [routeId]); + let { data } = await fetchAndDecode(args, basename, [routeId]); return unwrapSingleFetchResult(data, routeId); }); @@ -433,7 +428,7 @@ async function singleFetchLoaderNavigationStrategy( ? [...routesParams.keys()] : undefined; try { - let data = await fetchAndDecode(request, basename, targetRoutes); + let data = await fetchAndDecode(args, basename, targetRoutes); singleFetchDfd.resolve(data.data); } catch (e) { singleFetchDfd.reject(e); @@ -447,17 +442,16 @@ async function singleFetchLoaderNavigationStrategy( // Fetcher loader calls are much simpler than navigational loader calls async function singleFetchLoaderFetcherStrategy( - request: Request, - matches: DataStrategyFunctionArgs["matches"], + args: DataStrategyFunctionArgs, fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { - let fetcherMatch = matches.find((m) => m.unstable_shouldCallHandler()); + let fetcherMatch = args.matches.find((m) => m.unstable_shouldCallHandler()); invariant(fetcherMatch, "No fetcher match found"); let routeId = fetcherMatch.route.id; let result = await fetcherMatch.resolve(async (handler) => handler(async () => { - let { data } = await fetchAndDecode(request, basename, [routeId]); + let { data } = await fetchAndDecode(args, basename, [routeId]); return unwrapSingleFetchResult(data, routeId); }) ); @@ -508,10 +502,11 @@ export function singleFetchUrl( } async function fetchAndDecodeViaTurboStream( - request: Request, + args: DataStrategyFunctionArgs, basename: string | undefined, targetRoutes?: string[] ): Promise<{ status: number; data: DecodedSingleFetchResults }> { + let { request } = args; let url = singleFetchUrl(request.url, basename); if (request.method === "GET") { url = stripIndexParam(url); From 726b5249a80986eb9a2a53d0fb931e386559da19 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 15 Apr 2025 10:46:13 -0400 Subject: [PATCH 18/25] Add script for starting a prerelease --- scripts/start-prerelease.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100755 scripts/start-prerelease.sh diff --git a/scripts/start-prerelease.sh b/scripts/start-prerelease.sh new file mode 100755 index 0000000000..9309e8414d --- /dev/null +++ b/scripts/start-prerelease.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -x +set -e + +if [[ -n $(git status --porcelain) ]]; then + echo "Error: Your git working directory is not clean. Please commit or stash your changes." + exit 1 +fi + +git checkout main +git pull +git checkout dev +git pull +git checkout -b release-next +git merge main --no-edit +pnpm changeset pre enter pre +git add .changeset/pre.json +git commit -m "Enter prerelease mode" +git push --set-upstream origin release-next + +set +e +set +x \ No newline at end of file From d04ce51755bffcd0ffdf8e76dfadd0af8cf4896b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 15 Apr 2025 10:46:23 -0400 Subject: [PATCH 19/25] Enter prerelease mode --- .changeset/pre.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000..ea5937b521 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,32 @@ +{ + "mode": "pre", + "tag": "pre", + "initialVersions": { + "integration": "0.0.0", + "integration-cloudflare-dev-proxy-template": "0.0.0", + "integration-vite-5-template": "0.0.0", + "integration-vite-6-template": "0.0.0", + "integration-vite-plugin-cloudflare-template": "0.0.0", + "integration-vite-rolldown-template": "0.0.0", + "create-react-router": "7.5.0", + "react-router": "7.5.0", + "@react-router/architect": "7.5.0", + "@react-router/cloudflare": "7.5.0", + "@react-router/dev": "7.5.0", + "react-router-dom": "7.5.0", + "@react-router/express": "7.5.0", + "@react-router/fs-routes": "7.5.0", + "@react-router/node": "7.5.0", + "@react-router/remix-routes-option-adapter": "7.5.0", + "@react-router/serve": "7.5.0", + "@playground/framework": "0.0.0", + "@playground/framework-express": "0.0.0", + "@playground/framework-rolldown-vite": "0.0.0", + "@playground/framework-spa": "0.0.0", + "@playground/framework-vite-5": "0.0.0", + "@playground/split-route-modules": "0.0.0", + "@playground/split-route-modules-spa": "0.0.0", + "@playground/vite-plugin-cloudflare": "0.0.0" + }, + "changesets": [] +} From 6ce4a79774f6f9734b7457463768bc2860398263 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:49:16 -0400 Subject: [PATCH 20/25] chore: Update version for release (pre) (#13412) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/pre.json | 12 ++++++- packages/create-react-router/CHANGELOG.md | 2 ++ packages/create-react-router/package.json | 2 +- packages/react-router-architect/CHANGELOG.md | 8 +++++ packages/react-router-architect/package.json | 2 +- packages/react-router-cloudflare/CHANGELOG.md | 7 ++++ packages/react-router-cloudflare/package.json | 2 +- packages/react-router-dev/CHANGELOG.md | 10 ++++++ packages/react-router-dev/package.json | 2 +- packages/react-router-dom/CHANGELOG.md | 7 ++++ packages/react-router-dom/package.json | 2 +- packages/react-router-express/CHANGELOG.md | 8 +++++ packages/react-router-express/package.json | 2 +- packages/react-router-fs-routes/CHANGELOG.md | 7 ++++ packages/react-router-fs-routes/package.json | 2 +- packages/react-router-node/CHANGELOG.md | 7 ++++ packages/react-router-node/package.json | 2 +- .../CHANGELOG.md | 7 ++++ .../package.json | 2 +- packages/react-router-serve/CHANGELOG.md | 9 +++++ packages/react-router-serve/package.json | 2 +- packages/react-router/CHANGELOG.md | 36 +++++++++++++++++++ packages/react-router/package.json | 2 +- 23 files changed, 130 insertions(+), 12 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index ea5937b521..b5b40f7764 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -28,5 +28,15 @@ "@playground/split-route-modules-spa": "0.0.0", "@playground/vite-plugin-cloudflare": "0.0.0" }, - "changesets": [] + "changesets": [ + "fair-weeks-beam", + "happy-spoons-watch", + "orange-sloths-tease", + "silent-snakes-mix", + "sixty-tigers-poke", + "smart-ads-doubt", + "smart-ligers-lay", + "violet-carrots-work", + "yellow-mangos-impress" + ] } diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index f2389a1fe9..19aee9f8f1 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,5 +1,7 @@ # `create-react-router` +## 7.5.1-pre.0 + ## 7.5.0 _No changes_ diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index 3b9eb35514..3441ceb66c 100644 --- a/packages/create-react-router/package.json +++ b/packages/create-react-router/package.json @@ -1,6 +1,6 @@ { "name": "create-react-router", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Create a new React Router app", "homepage": "https://reactrouter.com/", "bugs": { diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index d1fd97f236..58edc8b07f 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/architect` +## 7.5.1-pre.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1-pre.0` + - `@react-router/node@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index 338b6b5af4..a45e029ef2 100644 --- a/packages/react-router-architect/package.json +++ b/packages/react-router-architect/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/architect", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Architect server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md index 669bccf158..d51e382354 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/cloudflare` +## 7.5.1-pre.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index 6c37a6e3de..b8f4cce14b 100644 --- a/packages/react-router-cloudflare/package.json +++ b/packages/react-router-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/cloudflare", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Cloudflare platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index c4c6cb6589..d96e694850 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,5 +1,15 @@ # `@react-router/dev` +## 7.5.1-pre.0 + +### Patch Changes + +- Fix prerendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) +- Updated dependencies: + - `react-router@7.5.1-pre.0` + - `@react-router/node@7.5.1-pre.0` + - `@react-router/serve@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 28ba35bc3b..6b0c65bbe0 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/dev", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com/", "bugs": { diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 3609e587e4..44b7c0adbc 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,12 @@ # react-router-dom +## 7.5.1-pre.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index 5526d99435..abc6d9a621 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Declarative routing for React web applications", "keywords": [ "react", diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md index cc01059c42..88c9a2d101 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/express` +## 7.5.1-pre.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1-pre.0` + - `@react-router/node@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index 1daecbcba3..1baa1acb0f 100644 --- a/packages/react-router-express/package.json +++ b/packages/react-router-express/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/express", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Express server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-fs-routes/CHANGELOG.md b/packages/react-router-fs-routes/CHANGELOG.md index f7b9bfad12..9f823e7419 100644 --- a/packages/react-router-fs-routes/CHANGELOG.md +++ b/packages/react-router-fs-routes/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/fs-routes` +## 7.5.1-pre.0 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index 4080e7d819..f0e14091d6 100644 --- a/packages/react-router-fs-routes/package.json +++ b/packages/react-router-fs-routes/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/fs-routes", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "File system routing conventions for React Router, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md index 4833d1984f..77a0f484f5 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/node` +## 7.5.1-pre.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index 7971c7196a..a4116ff572 100644 --- a/packages/react-router-node/package.json +++ b/packages/react-router-node/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/node", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Node.js platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md index f3886ffc33..c74eea3661 100644 --- a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md +++ b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/remix-config-routes-adapter` +## 7.5.1-pre.0 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json index 46152b4126..5b673184c8 100644 --- a/packages/react-router-remix-routes-option-adapter/package.json +++ b/packages/react-router-remix-routes-option-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/remix-routes-option-adapter", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Adapter for Remix's \"routes\" config option, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md index a4e8235eec..d1a21fc219 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@react-router/serve` +## 7.5.1-pre.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1-pre.0` + - `@react-router/node@7.5.1-pre.0` + - `@react-router/express@7.5.1-pre.0` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index 7dead65971..9967672f64 100644 --- a/packages/react-router-serve/package.json +++ b/packages/react-router-serve/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/serve", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Production application server for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 0a70cb7365..c0835f3de5 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,41 @@ # `react-router` +## 7.5.1-pre.0 + +### Patch Changes + +- Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route ([#13253](https://github.com/remix-run/react-router/pull/13253)) +- When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration. ([#13376](https://github.com/remix-run/react-router/pull/13376)) + + If you move the code for these properties into a separate file, you can use this optimization to avoid downloading unused hydration code. For example: + + ```ts + createBrowserRouter([ + { + path: "/show/:showId", + lazy: { + loader: async () => (await import("./show.loader.js")).loader, + Component: async () => (await import("./show.component.js")).Component, + HydrateFallback: async () => + (await import("./show.hydrate-fallback.js")).HydrateFallback, + }, + }, + ]); + ``` + +- Properly revalidate prerendered paths when param values change ([#13380](https://github.com/remix-run/react-router/pull/13380)) +- UNSTABLE: Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations ([#13395](https://github.com/remix-run/react-router/pull/13395)) +- UNSTABLE: Add better error messaging when `getLoadContext` is not updated to return a `Map`" ([#13242](https://github.com/remix-run/react-router/pull/13242)) +- Do not automatically add `null` to `staticHandler.query()` `context.loaderData` if routes do not have loaders ([#13223](https://github.com/remix-run/react-router/pull/13223)) + + - This was a Remix v2 implementation detail inadvertently left in for React Router v7 + - Now that we allow returning `undefined` from loaders, our prior check of `loaderData[routeId] !== undefined` was no longer sufficient and was changed to a `routeId in loaderData` check - these `null` values can cause issues for this new check + - ⚠️ This could be a "breaking bug fix" for you if you are doing manual SSR with `createStaticHandler()`/``, and using `context.loaderData` to control `` hydration behavior on the client + +- Fix prerendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) +- UNSTABLE: Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled ([#13381](https://github.com/remix-run/react-router/pull/13381)) +- Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy` ([#13253](https://github.com/remix-run/react-router/pull/13253)) + ## 7.5.0 ### Minor Changes diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 17b0370f30..67d8c53c07 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "7.5.0", + "version": "7.5.1-pre.0", "description": "Declarative routing for React", "keywords": [ "react", From 360726dfe8510baf66db8235ff841b42adcf95b0 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 15 Apr 2025 10:54:26 -0400 Subject: [PATCH 21/25] Draft release notes --- CHANGELOG.md | 207 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 127 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index facdff17d4..a4a097ea76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,26 +13,29 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) + - [v7.5.1](#v751) + - [Patch Changes](#patch-changes) + - [Unstable Changes](#unstable-changes) - [v7.5.0](#v750) - [What's Changed](#whats-changed) - [`route.lazy` Object API](#routelazy-object-api) - [Minor Changes](#minor-changes) - - [Patch Changes](#patch-changes) - - [Unstable Changes](#unstable-changes) + - [Patch Changes](#patch-changes-1) + - [Unstable Changes](#unstable-changes-1) - [Changes by Package](#changes-by-package) - [v7.4.1](#v741) - [Security Notice](#security-notice) - - [Patch Changes](#patch-changes-1) - - [Unstable Changes](#unstable-changes-1) - - [v7.4.0](#v740) - - [Minor Changes](#minor-changes-1) - [Patch Changes](#patch-changes-2) - [Unstable Changes](#unstable-changes-2) + - [v7.4.0](#v740) + - [Minor Changes](#minor-changes-1) + - [Patch Changes](#patch-changes-3) + - [Unstable Changes](#unstable-changes-3) - [Changes by Package](#changes-by-package-1) - [v7.3.0](#v730) - [Minor Changes](#minor-changes-2) - - [Patch Changes](#patch-changes-3) - - [Unstable Changes](#unstable-changes-3) + - [Patch Changes](#patch-changes-4) + - [Unstable Changes](#unstable-changes-4) - [Client-side `context` (unstable)](#client-side-context-unstable) - [Middleware (unstable)](#middleware-unstable) - [Middleware `context` parameter](#middleware-context-parameter) @@ -44,28 +47,28 @@ We manage release notes in this file instead of the paginated Github Releases Pa - [Prerendering with a SPA Fallback](#prerendering-with-a-spa-fallback) - [Allow a root `loader` in SPA Mode](#allow-a-root-loader-in-spa-mode) - [Minor Changes](#minor-changes-3) - - [Patch Changes](#patch-changes-4) - - [Unstable Changes](#unstable-changes-4) + - [Patch Changes](#patch-changes-5) + - [Unstable Changes](#unstable-changes-5) - [Split Route Modules (unstable)](#split-route-modules-unstable) - [Changes by Package](#changes-by-package-3) - [v7.1.5](#v715) - - [Patch Changes](#patch-changes-5) - - [v7.1.4](#v714) - [Patch Changes](#patch-changes-6) - - [v7.1.3](#v713) + - [v7.1.4](#v714) - [Patch Changes](#patch-changes-7) - - [v7.1.2](#v712) + - [v7.1.3](#v713) - [Patch Changes](#patch-changes-8) - - [v7.1.1](#v711) + - [v7.1.2](#v712) - [Patch Changes](#patch-changes-9) + - [v7.1.1](#v711) + - [Patch Changes](#patch-changes-10) - [v7.1.0](#v710) - [Minor Changes](#minor-changes-4) - - [Patch Changes](#patch-changes-10) + - [Patch Changes](#patch-changes-11) - [Changes by Package](#changes-by-package-4) - [v7.0.2](#v702) - - [Patch Changes](#patch-changes-11) - - [v7.0.1](#v701) - [Patch Changes](#patch-changes-12) + - [v7.0.1](#v701) + - [Patch Changes](#patch-changes-13) - [v7.0.0](#v700) - [Breaking Changes](#breaking-changes) - [Package Restructuring](#package-restructuring) @@ -82,199 +85,199 @@ We manage release notes in this file instead of the paginated Github Releases Pa - [Major Changes (`react-router`)](#major-changes-react-router) - [Major Changes (`@react-router/*`)](#major-changes-react-router-1) - [Minor Changes](#minor-changes-5) - - [Patch Changes](#patch-changes-13) + - [Patch Changes](#patch-changes-14) - [Changes by Package](#changes-by-package-5) - [React Router v6 Releases](#react-router-v6-releases) - [v6.30.0](#v6300) - [Minor Changes](#minor-changes-6) - - [Patch Changes](#patch-changes-14) + - [Patch Changes](#patch-changes-15) - [v6.29.0](#v6290) - [Minor Changes](#minor-changes-7) - - [Patch Changes](#patch-changes-15) - - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-16) - - [v6.28.1](#v6281) + - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-17) + - [v6.28.1](#v6281) + - [Patch Changes](#patch-changes-18) - [v6.28.0](#v6280) - [What's Changed](#whats-changed-2) - [Minor Changes](#minor-changes-8) - - [Patch Changes](#patch-changes-18) + - [Patch Changes](#patch-changes-19) - [v6.27.0](#v6270) - [What's Changed](#whats-changed-3) - [Stabilized APIs](#stabilized-apis) - [Minor Changes](#minor-changes-9) - - [Patch Changes](#patch-changes-19) - - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-20) - - [v6.26.1](#v6261) + - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-21) + - [v6.26.1](#v6261) + - [Patch Changes](#patch-changes-22) - [v6.26.0](#v6260) - [Minor Changes](#minor-changes-10) - - [Patch Changes](#patch-changes-22) - - [v6.25.1](#v6251) - [Patch Changes](#patch-changes-23) + - [v6.25.1](#v6251) + - [Patch Changes](#patch-changes-24) - [v6.25.0](#v6250) - [What's Changed](#whats-changed-4) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - [Minor Changes](#minor-changes-11) - - [Patch Changes](#patch-changes-24) - - [v6.24.1](#v6241) - [Patch Changes](#patch-changes-25) + - [v6.24.1](#v6241) + - [Patch Changes](#patch-changes-26) - [v6.24.0](#v6240) - [What's Changed](#whats-changed-5) - [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war) - [Minor Changes](#minor-changes-12) - - [Patch Changes](#patch-changes-26) - - [v6.23.1](#v6231) - [Patch Changes](#patch-changes-27) + - [v6.23.1](#v6231) + - [Patch Changes](#patch-changes-28) - [v6.23.0](#v6230) - [What's Changed](#whats-changed-6) - [Data Strategy (unstable)](#data-strategy-unstable) - [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable) - [Minor Changes](#minor-changes-13) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-28) - - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-29) - - [v6.22.1](#v6221) + - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-30) + - [v6.22.1](#v6221) + - [Patch Changes](#patch-changes-31) - [v6.22.0](#v6220) - [What's Changed](#whats-changed-7) - [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag) - [Minor Changes](#minor-changes-14) - - [Patch Changes](#patch-changes-31) - - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-32) - - [v6.21.2](#v6212) + - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-33) - - [v6.21.1](#v6211) + - [v6.21.2](#v6212) - [Patch Changes](#patch-changes-34) + - [v6.21.1](#v6211) + - [Patch Changes](#patch-changes-35) - [v6.21.0](#v6210) - [What's Changed](#whats-changed-8) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-35) - - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-36) + - [v6.20.1](#v6201) + - [Patch Changes](#patch-changes-37) - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-16) - - [Patch Changes](#patch-changes-37) + - [Patch Changes](#patch-changes-38) - [v6.19.0](#v6190) - [What's Changed](#whats-changed-9) - [`unstable_flushSync` API](#unstable_flushsync-api) - [Minor Changes](#minor-changes-17) - - [Patch Changes](#patch-changes-38) + - [Patch Changes](#patch-changes-39) - [v6.18.0](#v6180) - [What's Changed](#whats-changed-10) - [New Fetcher APIs](#new-fetcher-apis) - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - [Minor Changes](#minor-changes-18) - - [Patch Changes](#patch-changes-39) + - [Patch Changes](#patch-changes-40) - [v6.17.0](#v6170) - [What's Changed](#whats-changed-11) - [View Transitions 🚀](#view-transitions-) - [Minor Changes](#minor-changes-19) - - [Patch Changes](#patch-changes-40) + - [Patch Changes](#patch-changes-41) - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-20) - - [Patch Changes](#patch-changes-41) + - [Patch Changes](#patch-changes-42) - [v6.15.0](#v6150) - [Minor Changes](#minor-changes-21) - - [Patch Changes](#patch-changes-42) - - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-43) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-44) + - [v6.14.1](#v6141) + - [Patch Changes](#patch-changes-45) - [v6.14.0](#v6140) - [What's Changed](#whats-changed-12) - [JSON/Text Submissions](#jsontext-submissions) - [Minor Changes](#minor-changes-22) - - [Patch Changes](#patch-changes-45) + - [Patch Changes](#patch-changes-46) - [v6.13.0](#v6130) - [What's Changed](#whats-changed-13) - [`future.v7_startTransition`](#futurev7_starttransition) - [Minor Changes](#minor-changes-23) - - [Patch Changes](#patch-changes-46) - - [v6.12.1](#v6121) - [Patch Changes](#patch-changes-47) + - [v6.12.1](#v6121) + - [Patch Changes](#patch-changes-48) - [v6.12.0](#v6120) - [What's Changed](#whats-changed-14) - [`React.startTransition` support](#reactstarttransition-support) - [Minor Changes](#minor-changes-24) - - [Patch Changes](#patch-changes-48) - - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-49) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-50) + - [v6.11.1](#v6111) + - [Patch Changes](#patch-changes-51) - [v6.11.0](#v6110) - [Minor Changes](#minor-changes-25) - - [Patch Changes](#patch-changes-51) + - [Patch Changes](#patch-changes-52) - [v6.10.0](#v6100) - [What's Changed](#whats-changed-15) - [Minor Changes](#minor-changes-26) - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - - [Patch Changes](#patch-changes-52) + - [Patch Changes](#patch-changes-53) - [v6.9.0](#v690) - [What's Changed](#whats-changed-16) - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - [Minor Changes](#minor-changes-27) - - [Patch Changes](#patch-changes-53) - - [v6.8.2](#v682) - [Patch Changes](#patch-changes-54) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-55) + - [v6.8.1](#v681) + - [Patch Changes](#patch-changes-56) - [v6.8.0](#v680) - [Minor Changes](#minor-changes-28) - - [Patch Changes](#patch-changes-56) + - [Patch Changes](#patch-changes-57) - [v6.7.0](#v670) - [Minor Changes](#minor-changes-29) - - [Patch Changes](#patch-changes-57) - - [v6.6.2](#v662) - [Patch Changes](#patch-changes-58) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-59) + - [v6.6.1](#v661) + - [Patch Changes](#patch-changes-60) - [v6.6.0](#v660) - [What's Changed](#whats-changed-17) - [Minor Changes](#minor-changes-30) - - [Patch Changes](#patch-changes-60) + - [Patch Changes](#patch-changes-61) - [v6.5.0](#v650) - [What's Changed](#whats-changed-18) - [Minor Changes](#minor-changes-31) - - [Patch Changes](#patch-changes-61) - - [v6.4.5](#v645) - [Patch Changes](#patch-changes-62) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-63) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-64) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-65) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-66) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-67) - [v6.4.0](#v640) - [What's Changed](#whats-changed-19) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-67) + - [Patch Changes](#patch-changes-68) - [v6.3.0](#v630) - [Minor Changes](#minor-changes-32) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-68) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-69) + - [v6.2.1](#v621) + - [Patch Changes](#patch-changes-70) - [v6.2.0](#v620) - [Minor Changes](#minor-changes-33) - - [Patch Changes](#patch-changes-70) - - [v6.1.1](#v611) - [Patch Changes](#patch-changes-71) + - [v6.1.1](#v611) + - [Patch Changes](#patch-changes-72) - [v6.1.0](#v610) - [Minor Changes](#minor-changes-34) - - [Patch Changes](#patch-changes-72) - - [v6.0.2](#v602) - [Patch Changes](#patch-changes-73) - - [v6.0.1](#v601) + - [v6.0.2](#v602) - [Patch Changes](#patch-changes-74) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-75) - [v6.0.0](#v600) @@ -316,6 +319,50 @@ Date: YYYY-MM-DD **Full Changelog**: [`v7.X.Y...v7.X.Y`](https://github.com/remix-run/react-router/compare/react-router@7.X.Y...react-router@7.X.Y) --> +## v7.5.1 + +Date: 2025-04-17 + +### Patch Changes + +- `react-router` - When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration. ([#13376](https://github.com/remix-run/react-router/pull/13376)) + + - If you move the code for these properties into a separate file, you can use this optimization to avoid downloading unused hydration code. For example: + + ```ts + createBrowserRouter([ + { + path: "/show/:showId", + lazy: { + loader: async () => (await import("./show.loader.js")).loader, + Component: async () => + (await import("./show.component.js")).Component, + HydrateFallback: async () => + (await import("./show.hydrate-fallback.js")).HydrateFallback, + }, + }, + ]); + ``` + +- `react-router` - Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route ([#13253](https://github.com/remix-run/react-router/pull/13253)) +- `react-router` - Properly revalidate pre-rendered paths when param values change ([#13380](https://github.com/remix-run/react-router/pull/13380)) +- `react-router` - Fix pre-rendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) +- `react-router` - Do not automatically add `null` to `staticHandler.query()` `context.loaderData` if routes do not have loaders ([#13223](https://github.com/remix-run/react-router/pull/13223)) + - This was a Remix v2 implementation detail inadvertently left in for React Router v7 + - Now that we allow returning `undefined` from loaders, our prior check of `loaderData[routeId] !== undefined` was no longer sufficient and was changed to a `routeId in loaderData` check - these `null` values can cause issues for this new check + - ⚠️ This could be a "breaking bug fix" for you if you are doing manual SSR with `createStaticHandler()`/``, and using `context.loaderData` to control `` hydration behavior on the client + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- `react-router` - UNSTABLE: Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations ([#13395](https://github.com/remix-run/react-router/pull/13395)) +- `react-router` - UNSTABLE: Add better error messaging when `getLoadContext` is not updated to return a `Map`" ([#13242](https://github.com/remix-run/react-router/pull/13242)) +- `react-router` - UNSTABLE: Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled ([#13381](https://github.com/remix-run/react-router/pull/13381)) +- `react-router` - Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy` ([#13253](https://github.com/remix-run/react-router/pull/13253)) + +**Full Changelog**: [`v7.5.0...v7.5.1`](https://github.com/remix-run/react-router/compare/react-router@7.5.0...react-router@7.5.1) + ## v7.5.0 Date: 2025-04-04 From 77f693c563e181928a9d8161a862985184ed78a2 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 15 Apr 2025 10:55:48 -0400 Subject: [PATCH 22/25] Update release notes --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a097ea76..37bdefd6a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -356,9 +356,9 @@ Date: 2025-04-17 ⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ -- `react-router` - UNSTABLE: Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations ([#13395](https://github.com/remix-run/react-router/pull/13395)) -- `react-router` - UNSTABLE: Add better error messaging when `getLoadContext` is not updated to return a `Map`" ([#13242](https://github.com/remix-run/react-router/pull/13242)) -- `react-router` - UNSTABLE: Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled ([#13381](https://github.com/remix-run/react-router/pull/13381)) +- `react-router` - Add better error messaging when `getLoadContext` is not updated to return a `Map` ([#13242](https://github.com/remix-run/react-router/pull/13242)) +- `react-router` - Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled ([#13381](https://github.com/remix-run/react-router/pull/13381)) +- `react-router` - Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations ([#13395](https://github.com/remix-run/react-router/pull/13395)) - `react-router` - Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy` ([#13253](https://github.com/remix-run/react-router/pull/13253)) **Full Changelog**: [`v7.5.0...v7.5.1`](https://github.com/remix-run/react-router/compare/react-router@7.5.0...react-router@7.5.1) From 6734abc722b51450439488a8f1c2e5f11c697a2f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 15 Apr 2025 12:39:03 -0400 Subject: [PATCH 23/25] Update release notes --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37bdefd6a9..977b7a65ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -325,9 +325,9 @@ Date: 2025-04-17 ### Patch Changes -- `react-router` - When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration. ([#13376](https://github.com/remix-run/react-router/pull/13376)) +- `react-router` - When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration ([#13376](https://github.com/remix-run/react-router/pull/13376)) - - If you move the code for these properties into a separate file, you can use this optimization to avoid downloading unused hydration code. For example: + - If you move the code for these properties into a separate file, since the hydrate properties were unused already (if the route wasn't present during hydration), you can avoid downloading them at all. For example: ```ts createBrowserRouter([ @@ -345,7 +345,7 @@ Date: 2025-04-17 ``` - `react-router` - Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route ([#13253](https://github.com/remix-run/react-router/pull/13253)) -- `react-router` - Properly revalidate pre-rendered paths when param values change ([#13380](https://github.com/remix-run/react-router/pull/13380)) +- `react-router` - Properly revalidate pre-rendered paths when param values change when using `ssr:false` + `prerender` configs ([#13380](https://github.com/remix-run/react-router/pull/13380)) - `react-router` - Fix pre-rendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) - `react-router` - Do not automatically add `null` to `staticHandler.query()` `context.loaderData` if routes do not have loaders ([#13223](https://github.com/remix-run/react-router/pull/13223)) - This was a Remix v2 implementation detail inadvertently left in for React Router v7 From 1c82938d0933f5a02074f3e182ef770c9d7ad29f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 17 Apr 2025 11:22:47 -0400 Subject: [PATCH 24/25] Exit prerelease mode --- .changeset/pre.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index b5b40f7764..2623723a59 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "pre", "initialVersions": { "integration": "0.0.0", From 5dd7c1580f2d782bded3f906a66d57005b083db9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:25:55 -0400 Subject: [PATCH 25/25] chore: Update version for release (#13422) --- .changeset/fair-weeks-beam.md | 5 --- .changeset/happy-spoons-watch.md | 21 ---------- .changeset/orange-sloths-tease.md | 5 --- .changeset/pre.json | 42 ------------------- .changeset/silent-snakes-mix.md | 5 --- .changeset/sixty-tigers-poke.md | 5 --- .changeset/smart-ads-doubt.md | 9 ---- .changeset/smart-ligers-lay.md | 6 --- .changeset/violet-carrots-work.md | 5 --- .changeset/yellow-mangos-impress.md | 5 --- packages/create-react-router/CHANGELOG.md | 4 +- packages/create-react-router/package.json | 2 +- packages/react-router-architect/CHANGELOG.md | 6 +-- packages/react-router-architect/package.json | 2 +- packages/react-router-cloudflare/CHANGELOG.md | 4 +- packages/react-router-cloudflare/package.json | 2 +- packages/react-router-dev/CHANGELOG.md | 8 ++-- packages/react-router-dev/package.json | 2 +- packages/react-router-dom/CHANGELOG.md | 4 +- packages/react-router-dom/package.json | 2 +- packages/react-router-express/CHANGELOG.md | 6 +-- packages/react-router-express/package.json | 2 +- packages/react-router-fs-routes/CHANGELOG.md | 4 +- packages/react-router-fs-routes/package.json | 2 +- packages/react-router-node/CHANGELOG.md | 4 +- packages/react-router-node/package.json | 2 +- .../CHANGELOG.md | 4 +- .../package.json | 2 +- packages/react-router-serve/CHANGELOG.md | 8 ++-- packages/react-router-serve/package.json | 2 +- packages/react-router/CHANGELOG.md | 8 +++- packages/react-router/package.json | 2 +- 32 files changed, 45 insertions(+), 145 deletions(-) delete mode 100644 .changeset/fair-weeks-beam.md delete mode 100644 .changeset/happy-spoons-watch.md delete mode 100644 .changeset/orange-sloths-tease.md delete mode 100644 .changeset/pre.json delete mode 100644 .changeset/silent-snakes-mix.md delete mode 100644 .changeset/sixty-tigers-poke.md delete mode 100644 .changeset/smart-ads-doubt.md delete mode 100644 .changeset/smart-ligers-lay.md delete mode 100644 .changeset/violet-carrots-work.md delete mode 100644 .changeset/yellow-mangos-impress.md diff --git a/.changeset/fair-weeks-beam.md b/.changeset/fair-weeks-beam.md deleted file mode 100644 index 91058cfe09..0000000000 --- a/.changeset/fair-weeks-beam.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router": patch ---- - -Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route diff --git a/.changeset/happy-spoons-watch.md b/.changeset/happy-spoons-watch.md deleted file mode 100644 index 7ca3078b00..0000000000 --- a/.changeset/happy-spoons-watch.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"react-router": patch ---- - -When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration. - -If you move the code for these properties into a separate file, you can use this optimization to avoid downloading unused hydration code. For example: - -```ts -createBrowserRouter([ - { - path: "/show/:showId", - lazy: { - loader: async () => (await import("./show.loader.js")).loader, - Component: async () => (await import("./show.component.js")).Component, - HydrateFallback: async () => - (await import("./show.hydrate-fallback.js")).HydrateFallback, - }, - }, -]); -``` diff --git a/.changeset/orange-sloths-tease.md b/.changeset/orange-sloths-tease.md deleted file mode 100644 index 6fe10ad2d1..0000000000 --- a/.changeset/orange-sloths-tease.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router": patch ---- - -Properly revalidate prerendered paths when param values change diff --git a/.changeset/pre.json b/.changeset/pre.json deleted file mode 100644 index 2623723a59..0000000000 --- a/.changeset/pre.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "mode": "exit", - "tag": "pre", - "initialVersions": { - "integration": "0.0.0", - "integration-cloudflare-dev-proxy-template": "0.0.0", - "integration-vite-5-template": "0.0.0", - "integration-vite-6-template": "0.0.0", - "integration-vite-plugin-cloudflare-template": "0.0.0", - "integration-vite-rolldown-template": "0.0.0", - "create-react-router": "7.5.0", - "react-router": "7.5.0", - "@react-router/architect": "7.5.0", - "@react-router/cloudflare": "7.5.0", - "@react-router/dev": "7.5.0", - "react-router-dom": "7.5.0", - "@react-router/express": "7.5.0", - "@react-router/fs-routes": "7.5.0", - "@react-router/node": "7.5.0", - "@react-router/remix-routes-option-adapter": "7.5.0", - "@react-router/serve": "7.5.0", - "@playground/framework": "0.0.0", - "@playground/framework-express": "0.0.0", - "@playground/framework-rolldown-vite": "0.0.0", - "@playground/framework-spa": "0.0.0", - "@playground/framework-vite-5": "0.0.0", - "@playground/split-route-modules": "0.0.0", - "@playground/split-route-modules-spa": "0.0.0", - "@playground/vite-plugin-cloudflare": "0.0.0" - }, - "changesets": [ - "fair-weeks-beam", - "happy-spoons-watch", - "orange-sloths-tease", - "silent-snakes-mix", - "sixty-tigers-poke", - "smart-ads-doubt", - "smart-ligers-lay", - "violet-carrots-work", - "yellow-mangos-impress" - ] -} diff --git a/.changeset/silent-snakes-mix.md b/.changeset/silent-snakes-mix.md deleted file mode 100644 index cba0468503..0000000000 --- a/.changeset/silent-snakes-mix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router": patch ---- - -UNSTABLE: Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations diff --git a/.changeset/sixty-tigers-poke.md b/.changeset/sixty-tigers-poke.md deleted file mode 100644 index d59eb15ed3..0000000000 --- a/.changeset/sixty-tigers-poke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router": patch ---- - -UNSTABLE: Add better error messaging when `getLoadContext` is not updated to return a `Map`" diff --git a/.changeset/smart-ads-doubt.md b/.changeset/smart-ads-doubt.md deleted file mode 100644 index 5ac7306418..0000000000 --- a/.changeset/smart-ads-doubt.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"react-router": patch ---- - -Do not automatically add `null` to `staticHandler.query()` `context.loaderData` if routes do not have loaders - -- This was a Remix v2 implementation detail inadvertently left in for React Router v7 -- Now that we allow returning `undefined` from loaders, our prior check of `loaderData[routeId] !== undefined` was no longer sufficient and was changed to a `routeId in loaderData` check - these `null` values can cause issues for this new check -- ⚠️ This could be a "breaking bug fix" for you if you are doing manual SSR with `createStaticHandler()`/``, and using `context.loaderData` to control `` hydration behavior on the client diff --git a/.changeset/smart-ligers-lay.md b/.changeset/smart-ligers-lay.md deleted file mode 100644 index ca831ce832..0000000000 --- a/.changeset/smart-ligers-lay.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@react-router/dev": patch -"react-router": patch ---- - -Fix prerendering when a loader returns a redirect diff --git a/.changeset/violet-carrots-work.md b/.changeset/violet-carrots-work.md deleted file mode 100644 index 127454578d..0000000000 --- a/.changeset/violet-carrots-work.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router": patch ---- - -UNSTABLE: Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled diff --git a/.changeset/yellow-mangos-impress.md b/.changeset/yellow-mangos-impress.md deleted file mode 100644 index 4071485a2b..0000000000 --- a/.changeset/yellow-mangos-impress.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router": patch ---- - -Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy` diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index 19aee9f8f1..03f4e9eaf2 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,6 +1,8 @@ # `create-react-router` -## 7.5.1-pre.0 +## 7.5.1 + +_No changes_ ## 7.5.0 diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index 3441ceb66c..71cfef4b9a 100644 --- a/packages/create-react-router/package.json +++ b/packages/create-react-router/package.json @@ -1,6 +1,6 @@ { "name": "create-react-router", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Create a new React Router app", "homepage": "https://reactrouter.com/", "bugs": { diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index 58edc8b07f..e780924ea8 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,12 +1,12 @@ # `@react-router/architect` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Updated dependencies: - - `react-router@7.5.1-pre.0` - - `@react-router/node@7.5.1-pre.0` + - `react-router@7.5.1` + - `@react-router/node@7.5.1` ## 7.5.0 diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index a45e029ef2..8fae1dd38b 100644 --- a/packages/react-router-architect/package.json +++ b/packages/react-router-architect/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/architect", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Architect server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md index d51e382354..f296000790 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,11 +1,11 @@ # `@react-router/cloudflare` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Updated dependencies: - - `react-router@7.5.1-pre.0` + - `react-router@7.5.1` ## 7.5.0 diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index b8f4cce14b..04032c2910 100644 --- a/packages/react-router-cloudflare/package.json +++ b/packages/react-router-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/cloudflare", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Cloudflare platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index d96e694850..47a840a414 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,14 +1,14 @@ # `@react-router/dev` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Fix prerendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) - Updated dependencies: - - `react-router@7.5.1-pre.0` - - `@react-router/node@7.5.1-pre.0` - - `@react-router/serve@7.5.1-pre.0` + - `react-router@7.5.1` + - `@react-router/node@7.5.1` + - `@react-router/serve@7.5.1` ## 7.5.0 diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 6b0c65bbe0..fe8ddd6cae 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/dev", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com/", "bugs": { diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 44b7c0adbc..686451a0dc 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,11 +1,11 @@ # react-router-dom -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Updated dependencies: - - `react-router@7.5.1-pre.0` + - `react-router@7.5.1` ## 7.5.0 diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index abc6d9a621..2ad165215b 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Declarative routing for React web applications", "keywords": [ "react", diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md index 88c9a2d101..7e4dd0cb93 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,12 +1,12 @@ # `@react-router/express` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Updated dependencies: - - `react-router@7.5.1-pre.0` - - `@react-router/node@7.5.1-pre.0` + - `react-router@7.5.1` + - `@react-router/node@7.5.1` ## 7.5.0 diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index 1baa1acb0f..6d619406d8 100644 --- a/packages/react-router-express/package.json +++ b/packages/react-router-express/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/express", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Express server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-fs-routes/CHANGELOG.md b/packages/react-router-fs-routes/CHANGELOG.md index 9f823e7419..a951bf9172 100644 --- a/packages/react-router-fs-routes/CHANGELOG.md +++ b/packages/react-router-fs-routes/CHANGELOG.md @@ -1,11 +1,11 @@ # `@react-router/fs-routes` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Updated dependencies: - - `@react-router/dev@7.5.1-pre.0` + - `@react-router/dev@7.5.1` ## 7.5.0 diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index f0e14091d6..2a6d3d8cf0 100644 --- a/packages/react-router-fs-routes/package.json +++ b/packages/react-router-fs-routes/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/fs-routes", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "File system routing conventions for React Router, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md index 77a0f484f5..6c94b7bf42 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,11 +1,11 @@ # `@react-router/node` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Updated dependencies: - - `react-router@7.5.1-pre.0` + - `react-router@7.5.1` ## 7.5.0 diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index a4116ff572..1148620eb7 100644 --- a/packages/react-router-node/package.json +++ b/packages/react-router-node/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/node", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Node.js platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md index c74eea3661..aab264a2a7 100644 --- a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md +++ b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md @@ -1,11 +1,11 @@ # `@react-router/remix-config-routes-adapter` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Updated dependencies: - - `@react-router/dev@7.5.1-pre.0` + - `@react-router/dev@7.5.1` ## 7.5.0 diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json index 5b673184c8..f0ae5a27f3 100644 --- a/packages/react-router-remix-routes-option-adapter/package.json +++ b/packages/react-router-remix-routes-option-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/remix-routes-option-adapter", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Adapter for Remix's \"routes\" config option, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md index d1a21fc219..28b5e311e7 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,13 +1,13 @@ # `@react-router/serve` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Updated dependencies: - - `react-router@7.5.1-pre.0` - - `@react-router/node@7.5.1-pre.0` - - `@react-router/express@7.5.1-pre.0` + - `react-router@7.5.1` + - `@react-router/node@7.5.1` + - `@react-router/express@7.5.1` ## 7.5.0 diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index 9967672f64..626f1a8d35 100644 --- a/packages/react-router-serve/package.json +++ b/packages/react-router-serve/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/serve", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Production application server for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index c0835f3de5..521ca6cb52 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,10 +1,11 @@ # `react-router` -## 7.5.1-pre.0 +## 7.5.1 ### Patch Changes - Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route ([#13253](https://github.com/remix-run/react-router/pull/13253)) + - When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration. ([#13376](https://github.com/remix-run/react-router/pull/13376)) If you move the code for these properties into a separate file, you can use this optimization to avoid downloading unused hydration code. For example: @@ -24,8 +25,11 @@ ``` - Properly revalidate prerendered paths when param values change ([#13380](https://github.com/remix-run/react-router/pull/13380)) + - UNSTABLE: Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations ([#13395](https://github.com/remix-run/react-router/pull/13395)) + - UNSTABLE: Add better error messaging when `getLoadContext` is not updated to return a `Map`" ([#13242](https://github.com/remix-run/react-router/pull/13242)) + - Do not automatically add `null` to `staticHandler.query()` `context.loaderData` if routes do not have loaders ([#13223](https://github.com/remix-run/react-router/pull/13223)) - This was a Remix v2 implementation detail inadvertently left in for React Router v7 @@ -33,7 +37,9 @@ - ⚠️ This could be a "breaking bug fix" for you if you are doing manual SSR with `createStaticHandler()`/``, and using `context.loaderData` to control `` hydration behavior on the client - Fix prerendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) + - UNSTABLE: Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled ([#13381](https://github.com/remix-run/react-router/pull/13381)) + - Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy` ([#13253](https://github.com/remix-run/react-router/pull/13253)) ## 7.5.0 diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 67d8c53c07..add094b2db 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "7.5.1-pre.0", + "version": "7.5.1", "description": "Declarative routing for React", "keywords": [ "react",