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(`
+ ""
+ `);
+
+ 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()