Skip to content

Commit 0f04d11

Browse files
authored
Fix issues with partial hydration combined with route.lazy (#11121)
1 parent 87d5d61 commit 0f04d11

File tree

5 files changed

+268
-43
lines changed

5 files changed

+268
-43
lines changed

.changeset/lazy-partial-hydration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"react-router": patch
3+
"@remix-run/router": patch
4+
---
5+
6+
Fix bug with `route.lazy` not working correctly on initial SPA load when `v7_partialHydration` is specified

packages/react-router-dom/__tests__/partial-hydration-test.tsx

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from "react-router-dom";
1515

1616
import getHtml from "../../react-router/__tests__/utils/getHtml";
17-
import { createDeferred } from "../../router/__tests__/utils/utils";
17+
import { createDeferred, tick } from "../../router/__tests__/utils/utils";
1818

1919
let didAssertMissingHydrateFallback = false;
2020

@@ -521,4 +521,133 @@ function testPartialHydration(
521521
</div>"
522522
`);
523523
});
524+
525+
it("supports partial hydration w/lazy initial routes (leaf fallback)", async () => {
526+
let dfd = createDeferred();
527+
let router = createTestRouter(
528+
[
529+
{
530+
path: "/",
531+
Component() {
532+
return (
533+
<>
534+
<h1>Root</h1>
535+
<Outlet />
536+
</>
537+
);
538+
},
539+
children: [
540+
{
541+
id: "index",
542+
index: true,
543+
HydrateFallback: () => <p>Index Loading...</p>,
544+
async lazy() {
545+
await tick();
546+
return {
547+
loader: () => dfd.promise,
548+
Component() {
549+
let data = useLoaderData() as string;
550+
return <h2>{`Index - ${data}`}</h2>;
551+
},
552+
};
553+
},
554+
},
555+
],
556+
},
557+
],
558+
{
559+
future: {
560+
v7_partialHydration: true,
561+
},
562+
}
563+
);
564+
let { container } = render(<RouterProvider router={router} />);
565+
566+
expect(getHtml(container)).toMatchInlineSnapshot(`
567+
"<div>
568+
<h1>
569+
Root
570+
</h1>
571+
<p>
572+
Index Loading...
573+
</p>
574+
</div>"
575+
`);
576+
577+
dfd.resolve("INDEX DATA");
578+
await waitFor(() => screen.getByText(/INDEX DATA/));
579+
expect(getHtml(container)).toMatchInlineSnapshot(`
580+
"<div>
581+
<h1>
582+
Root
583+
</h1>
584+
<h2>
585+
Index - INDEX DATA
586+
</h2>
587+
</div>"
588+
`);
589+
});
590+
591+
it("supports partial hydration w/lazy initial routes (root fallback)", async () => {
592+
let dfd = createDeferred();
593+
let router = createTestRouter(
594+
[
595+
{
596+
path: "/",
597+
Component() {
598+
return (
599+
<>
600+
<h1>Root</h1>
601+
<Outlet />
602+
</>
603+
);
604+
},
605+
HydrateFallback: () => <p>Loading...</p>,
606+
children: [
607+
{
608+
id: "index",
609+
index: true,
610+
async lazy() {
611+
await tick();
612+
return {
613+
loader: () => dfd.promise,
614+
Component() {
615+
let data = useLoaderData() as string;
616+
return <h2>{`Index - ${data}`}</h2>;
617+
},
618+
};
619+
},
620+
},
621+
],
622+
},
623+
],
624+
{
625+
future: {
626+
v7_partialHydration: true,
627+
},
628+
}
629+
);
630+
let { container } = render(<RouterProvider router={router} />);
631+
632+
expect(getHtml(container)).toMatchInlineSnapshot(`
633+
"<div>
634+
<p>
635+
Loading...
636+
</p>
637+
</div>"
638+
`);
639+
640+
dfd.resolve("INDEX DATA");
641+
await waitFor(() => screen.getByText(/INDEX DATA/));
642+
expect(getHtml(container)).toMatchInlineSnapshot(`
643+
"<div>
644+
<h1>
645+
Root
646+
</h1>
647+
<h2>
648+
Index - INDEX DATA
649+
</h2>
650+
</div>"
651+
`);
652+
});
524653
}

packages/react-router/lib/hooks.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,8 @@ export function useRoutesImpl(
438438
warning(
439439
matches == null ||
440440
matches[matches.length - 1].route.element !== undefined ||
441-
matches[matches.length - 1].route.Component !== undefined,
441+
matches[matches.length - 1].route.Component !== undefined ||
442+
matches[matches.length - 1].route.lazy !== undefined,
442443
`Matched leaf route at location "${location.pathname}${location.search}${location.hash}" ` +
443444
`does not have an element or Component. This means it will render an <Outlet /> with a ` +
444445
`null value by default resulting in an "empty" page.`
@@ -704,23 +705,25 @@ export function _renderMatches(
704705
if (match.route.HydrateFallback || match.route.hydrateFallbackElement) {
705706
fallbackIndex = i;
706707
}
707-
if (
708-
match.route.loader &&
709-
match.route.id &&
710-
dataRouterState.loaderData[match.route.id] === undefined &&
711-
(!dataRouterState.errors ||
712-
dataRouterState.errors[match.route.id] === undefined)
713-
) {
714-
// We found the first route without data/errors which means it's loader
715-
// still needs to run. Flag that we need to render a fallback and
716-
// render up until the appropriate fallback
717-
renderFallback = true;
718-
if (fallbackIndex >= 0) {
719-
renderedMatches = renderedMatches.slice(0, fallbackIndex + 1);
720-
} else {
721-
renderedMatches = [renderedMatches[0]];
708+
709+
if (match.route.id) {
710+
let { loaderData, errors } = dataRouterState;
711+
let needsToRunLoader =
712+
match.route.loader &&
713+
loaderData[match.route.id] === undefined &&
714+
(!errors || errors[match.route.id] === undefined);
715+
if (match.route.lazy || needsToRunLoader) {
716+
// We found the first route that's not ready to render (waiting on
717+
// lazy, or has a loader that hasn't run yet). Flag that we need to
718+
// render a fallback and render up until the appropriate fallback
719+
renderFallback = true;
720+
if (fallbackIndex >= 0) {
721+
renderedMatches = renderedMatches.slice(0, fallbackIndex + 1);
722+
} else {
723+
renderedMatches = [renderedMatches[0]];
724+
}
725+
break;
722726
}
723-
break;
724727
}
725728
}
726729
}

packages/router/__tests__/lazy-test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,104 @@ describe("lazily loaded route modules", () => {
4343
},
4444
];
4545

46+
describe("initialization", () => {
47+
it("fetches lazy route modules on router initialization", async () => {
48+
let dfd = createDeferred();
49+
let router = createRouter({
50+
routes: [
51+
{
52+
path: "/lazy",
53+
lazy: () => dfd.promise,
54+
},
55+
],
56+
history: createMemoryHistory({ initialEntries: ["/lazy"] }),
57+
});
58+
59+
expect(router.state.initialized).toBe(false);
60+
61+
router.initialize();
62+
63+
let route = { Component: () => null };
64+
await dfd.resolve(route);
65+
66+
expect(router.state.location.pathname).toBe("/lazy");
67+
expect(router.state.navigation.state).toBe("idle");
68+
expect(router.state.initialized).toBe(true);
69+
expect(router.state.matches[0].route).toMatchObject(route);
70+
});
71+
72+
it("fetches lazy route modules and executes loaders on router initialization", async () => {
73+
let dfd = createDeferred();
74+
let router = createRouter({
75+
routes: [
76+
{
77+
path: "/lazy",
78+
lazy: () => dfd.promise,
79+
},
80+
],
81+
history: createMemoryHistory({ initialEntries: ["/lazy"] }),
82+
});
83+
84+
expect(router.state.initialized).toBe(false);
85+
86+
router.initialize();
87+
88+
let loaderDfd = createDeferred();
89+
let route = {
90+
Component: () => null,
91+
loader: () => loaderDfd.promise,
92+
};
93+
await dfd.resolve(route);
94+
expect(router.state.initialized).toBe(false);
95+
96+
await loaderDfd.resolve("LOADER");
97+
expect(router.state.location.pathname).toBe("/lazy");
98+
expect(router.state.navigation.state).toBe("idle");
99+
expect(router.state.initialized).toBe(true);
100+
expect(router.state.loaderData).toEqual({
101+
"0": "LOADER",
102+
});
103+
expect(router.state.matches[0].route).toMatchObject(route);
104+
});
105+
106+
it("fetches lazy route modules and executes loaders with v7_partialHydration enabled", async () => {
107+
let dfd = createDeferred();
108+
let router = createRouter({
109+
routes: [
110+
{
111+
path: "/lazy",
112+
lazy: () => dfd.promise,
113+
},
114+
],
115+
history: createMemoryHistory({ initialEntries: ["/lazy"] }),
116+
future: {
117+
v7_partialHydration: true,
118+
},
119+
});
120+
121+
expect(router.state.initialized).toBe(false);
122+
123+
router.initialize();
124+
125+
let loaderDfd = createDeferred();
126+
let route = {
127+
Component: () => null,
128+
loader: () => loaderDfd.promise,
129+
};
130+
await dfd.resolve(route);
131+
expect(router.state.initialized).toBe(false);
132+
133+
await loaderDfd.resolve("LOADER");
134+
expect(router.state.location.pathname).toBe("/lazy");
135+
expect(router.state.navigation.state).toBe("idle");
136+
expect(router.state.initialized).toBe(true);
137+
expect(router.state.loaderData).toEqual({
138+
"0": "LOADER",
139+
});
140+
expect(router.state.matches[0].route).toMatchObject(route);
141+
});
142+
});
143+
46144
describe("happy path", () => {
47145
it("fetches lazy route modules on loading navigation", async () => {
48146
let t = setup({ routes: LAZY_ROUTES });

packages/router/router.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3655,21 +3655,27 @@ function getMatchesToLoad(
36553655
let boundaryMatches = getLoaderMatchesUntilBoundary(matches, boundaryId);
36563656

36573657
let navigationMatches = boundaryMatches.filter((match, index) => {
3658-
if (isInitialLoad) {
3659-
// On initial hydration we don't do any shouldRevalidate stuff - we just
3660-
// call the unhydrated loaders
3661-
return isUnhydratedRoute(state, match.route);
3662-
}
3663-
3664-
if (match.route.lazy) {
3658+
let { route } = match;
3659+
if (route.lazy) {
36653660
// We haven't loaded this route yet so we don't know if it's got a loader!
36663661
return true;
36673662
}
36683663

3669-
if (match.route.loader == null) {
3664+
if (route.loader == null) {
36703665
return false;
36713666
}
36723667

3668+
if (isInitialLoad) {
3669+
if (route.loader.hydrate) {
3670+
return true;
3671+
}
3672+
return (
3673+
state.loaderData[route.id] === undefined &&
3674+
// Don't re-run if the loader ran and threw an error
3675+
(!state.errors || state.errors[route.id] === undefined)
3676+
);
3677+
}
3678+
36733679
// Always call the loader on new route instances and pending defer cancellations
36743680
if (
36753681
isNewLoader(state.loaderData, state.matches[index], match) ||
@@ -3789,23 +3795,6 @@ function getMatchesToLoad(
37893795
return [navigationMatches, revalidatingFetchers];
37903796
}
37913797

3792-
// Is this route unhydrated (when v7_partialHydration=true) such that we need
3793-
// to call it's loader on the initial router creation
3794-
function isUnhydratedRoute(state: RouterState, route: AgnosticDataRouteObject) {
3795-
if (!route.loader) {
3796-
return false;
3797-
}
3798-
if (route.loader.hydrate) {
3799-
return true;
3800-
}
3801-
return (
3802-
state.loaderData[route.id] === undefined &&
3803-
(!state.errors ||
3804-
// Loader ran but errored - don't re-run
3805-
state.errors[route.id] === undefined)
3806-
);
3807-
}
3808-
38093798
function isNewLoader(
38103799
currentLoaderData: RouteData,
38113800
currentMatch: AgnosticDataRouteMatch,

0 commit comments

Comments
 (0)