From 653d1a873e1325fbfe207642f62f5a36ce308992 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 25 Jul 2024 11:07:57 -0400 Subject: [PATCH] Fix hydration behavior of patchRoutesOnMiss when v7_partialHydration is enabled (#11838) --- .changeset/sour-dryers-walk.md | 9 + package.json | 6 +- .../__tests__/partial-hydration-test.tsx | 180 +++++++++++++++++- packages/react-router/lib/hooks.tsx | 19 +- .../router/__tests__/lazy-discovery-test.ts | 123 ++++++++++++ packages/router/router.ts | 17 +- 6 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 .changeset/sour-dryers-walk.md diff --git a/.changeset/sour-dryers-walk.md b/.changeset/sour-dryers-walk.md new file mode 100644 index 0000000000..c1446de81c --- /dev/null +++ b/.changeset/sour-dryers-walk.md @@ -0,0 +1,9 @@ +--- +"react-router-dom": patch +"react-router": patch +"@remix-run/router": patch +--- + +Fix initial hydration behavior when using `future.v7_partialHydration` along with `unstable_patchRoutesOnMiss` + +- During initial hydration, `router.state.matches` will now include any partial matches so that we can render ancestor `HydrateFallback` components diff --git a/package.json b/package.json index 20e1e13cd4..4bf46e006f 100644 --- a/package.json +++ b/package.json @@ -105,13 +105,13 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "57.1 kB" + "none": "57.2 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "14.9 kB" + "none": "15.0 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "17.4 kB" + "none": "17.5 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { "none": "17.3 kB" diff --git a/packages/react-router-dom/__tests__/partial-hydration-test.tsx b/packages/react-router-dom/__tests__/partial-hydration-test.tsx index dac2f3d5d9..918178a978 100644 --- a/packages/react-router-dom/__tests__/partial-hydration-test.tsx +++ b/packages/react-router-dom/__tests__/partial-hydration-test.tsx @@ -2,7 +2,7 @@ import "@testing-library/jest-dom"; import { act, render, screen, waitFor } from "@testing-library/react"; import * as React from "react"; import type { LoaderFunction } from "react-router"; -import { RouterProvider as ReactRouter_RouterPRovider } from "react-router"; +import { RouterProvider as ReactRouter_RouterProvider } from "react-router"; import { Outlet, RouterProvider as ReactRouterDom_RouterProvider, @@ -28,7 +28,181 @@ describe("v7_partialHydration", () => { }); describe("createMemoryRouter", () => { - testPartialHydration(createMemoryRouter, ReactRouter_RouterPRovider); + testPartialHydration(createMemoryRouter, ReactRouter_RouterProvider); + + // these tests only run for memory since we just need to set initialEntries + it("supports partial hydration w/patchRoutesOnMiss (leaf fallback)", async () => { + let parentDfd = createDeferred(); + let childDfd = createDeferred(); + let router = createMemoryRouter( + [ + { + path: "/", + Component() { + return ( + <> +

Root

+ + + ); + }, + children: [ + { + id: "parent", + path: "parent", + HydrateFallback: () =>

Parent Loading...

, + loader: () => parentDfd.promise, + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Parent - ${data}`}

+ + + ); + }, + }, + ], + }, + ], + { + future: { + v7_partialHydration: true, + }, + unstable_patchRoutesOnMiss({ path, patch }) { + if (path === "/parent/child") { + patch("parent", [ + { + path: "child", + loader: () => childDfd.promise, + Component() { + let data = useLoaderData() as string; + return

{`Child - ${data}`}

; + }, + }, + ]); + } + }, + initialEntries: ["/parent/child"], + } + ); + let { container } = render( + + ); + + parentDfd.resolve("PARENT DATA"); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Root +

+

+ Parent Loading... +

+
" + `); + + childDfd.resolve("CHILD DATA"); + await waitFor(() => screen.getByText(/CHILD DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Root +

+

+ Parent - PARENT DATA +

+

+ Child - CHILD DATA +

+
" + `); + }); + + it("supports partial hydration w/patchRoutesOnMiss (root fallback)", async () => { + let parentDfd = createDeferred(); + let childDfd = createDeferred(); + let router = createMemoryRouter( + [ + { + path: "/", + HydrateFallback: () =>

Root Loading...

, + Component() { + return ( + <> +

Root

+ + + ); + }, + children: [ + { + id: "parent", + path: "parent", + loader: () => parentDfd.promise, + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Parent - ${data}`}

+ + + ); + }, + }, + ], + }, + ], + { + future: { + v7_partialHydration: true, + }, + unstable_patchRoutesOnMiss({ path, patch }) { + if (path === "/parent/child") { + patch("parent", [ + { + path: "child", + loader: () => childDfd.promise, + Component() { + let data = useLoaderData() as string; + return

{`Child - ${data}`}

; + }, + }, + ]); + } + }, + initialEntries: ["/parent/child"], + } + ); + let { container } = render( + + ); + + parentDfd.resolve("PARENT DATA"); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Root Loading... +

+
" + `); + + childDfd.resolve("CHILD DATA"); + await waitFor(() => screen.getByText(/CHILD DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Root +

+

+ Parent - PARENT DATA +

+

+ Child - CHILD DATA +

+
" + `); + }); }); }); @@ -39,7 +213,7 @@ function testPartialHydration( | typeof createMemoryRouter, RouterProvider: | typeof ReactRouterDom_RouterProvider - | typeof ReactRouter_RouterPRovider + | typeof ReactRouter_RouterProvider ) { let consoleWarn: jest.SpyInstance; diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index d57dfd1cc3..ccce084a93 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -683,10 +683,27 @@ export function _renderMatches( future: RemixRouter["future"] | null = null ): React.ReactElement | null { if (matches == null) { - if (dataRouterState?.errors) { + if (!dataRouterState) { + return null; + } + + if (dataRouterState.errors) { // Don't bail if we have data router errors so we can render them in the // boundary. Use the pre-matched (or shimmed) matches matches = dataRouterState.matches as DataRouteMatch[]; + } else if ( + future?.v7_partialHydration && + parentMatches.length === 0 && + !dataRouterState.initialized && + dataRouterState.matches.length > 0 + ) { + // Don't bail if we're initializing with partial hydration and we have + // router matches. That means we're actively running `patchRoutesOnMiss` + // so we should render down the partial matches to the appropriate + // `HydrateFallback`. We only do this if `parentMatches` is empty so it + // only impacts the root matches for `RouterProvider` and no descendant + // `` + matches = dataRouterState.matches as DataRouteMatch[]; } else { return null; } diff --git a/packages/router/__tests__/lazy-discovery-test.ts b/packages/router/__tests__/lazy-discovery-test.ts index 1f9d158639..68f6d41f7c 100644 --- a/packages/router/__tests__/lazy-discovery-test.ts +++ b/packages/router/__tests__/lazy-discovery-test.ts @@ -628,9 +628,11 @@ describe("Lazy Route Discovery (Fog of War)", () => { router.initialize(); expect(router.state.initialized).toBe(false); + expect(router.state.matches.length).toBe(0); loaderDfd.resolve("PARENT"); expect(router.state.initialized).toBe(false); + expect(router.state.matches.length).toBe(0); childrenDfd.resolve([ { @@ -640,6 +642,66 @@ describe("Lazy Route Discovery (Fog of War)", () => { }, ]); expect(router.state.initialized).toBe(false); + expect(router.state.matches.length).toBe(0); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.state.initialized).toBe(true); + expect(router.state.location.pathname).toBe("/parent/child"); + expect(router.state.loaderData).toEqual({ + parent: "PARENT", + child: "CHILD", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent", + "child", + ]); + }); + + it("discovers routes during initial hydration (w/v7_partialHydration)", async () => { + let childrenDfd = createDeferred(); + let loaderDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/parent/child"] }), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + loader: () => loaderDfd.promise, + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + let children = await childrenDfd.promise; + patch("parent", children); + }, + future: { + v7_partialHydration: true, + }, + }); + router.initialize(); + + expect(router.state.initialized).toBe(false); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["parent"]); + + loaderDfd.resolve("PARENT"); + expect(router.state.initialized).toBe(false); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["parent"]); + + childrenDfd.resolve([ + { + id: "child", + path: "child", + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.state.initialized).toBe(false); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["parent"]); childLoaderDfd.resolve("CHILD"); await tick(); @@ -1648,6 +1710,67 @@ describe("Lazy Route Discovery (Fog of War)", () => { "child", ]); }); + + it("bubbles errors thrown from patchRoutesOnMiss() during hydration (w/v7_partialHydration)", async () => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries: ["/parent/child/grandchild"], + }), + routes: [ + { + id: "parent", + path: "parent", + hasErrorBoundary: true, + children: [ + { + id: "child", + path: "child", + }, + ], + }, + ], + async unstable_patchRoutesOnMiss() { + await tick(); + throw new Error("broke!"); + }, + future: { + v7_partialHydration: true, + }, + }).initialize(); + + expect(router.state).toMatchObject({ + location: { pathname: "/parent/child/grandchild" }, + initialized: false, + errors: null, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent", + "child", + ]); + + await tick(); + expect(router.state).toMatchObject({ + location: { pathname: "/parent/child/grandchild" }, + actionData: null, + loaderData: {}, + errors: { + parent: new ErrorResponseImpl( + 400, + "Bad Request", + new Error( + 'Unable to match URL "/parent/child/grandchild" - the ' + + "`unstable_patchRoutesOnMiss()` function threw the following " + + "error:\nError: broke!" + ), + true + ), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent", + "child", + ]); + }); }); describe("fetchers", () => { diff --git a/packages/router/router.ts b/packages/router/router.ts index d6b9c8ab62..3c3ecf9051 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -848,7 +848,7 @@ export function createRouter(init: RouterInit): Router { // In SSR apps (with `hydrationData`), we expect that the server will send // up the proper matched routes so we don't want to run lazy discovery on // initial hydration and want to hydrate into the splat route. - if (initialMatches && patchRoutesOnMissImpl && !init.hydrationData) { + if (initialMatches && !init.hydrationData) { let fogOfWar = checkFogOfWar( initialMatches, dataRoutes, @@ -861,9 +861,22 @@ export function createRouter(init: RouterInit): Router { let initialized: boolean; if (!initialMatches) { - // We need to run patchRoutesOnMiss in initialize() initialized = false; initialMatches = []; + + // If partial hydration and fog of war is enabled, we will be running + // `patchRoutesOnMiss` during hydration so include any partial matches as + // the initial matches so we can properly render `HydrateFallback`'s + if (future.v7_partialHydration) { + let fogOfWar = checkFogOfWar( + null, + dataRoutes, + init.history.location.pathname + ); + if (fogOfWar.active && fogOfWar.matches) { + initialMatches = fogOfWar.matches; + } + } } else if (initialMatches.some((m) => m.route.lazy)) { // All initialMatches need to be loaded before we're ready. If we have lazy // functions around still then we'll need to run them in initialize()