Skip to content

Commit 3be88c6

Browse files
authored
Fix partial hydration bugs when hydrating with errors (#12070)
1 parent 51546cc commit 3be88c6

File tree

3 files changed

+197
-63
lines changed

3 files changed

+197
-63
lines changed

.changeset/soft-maps-fix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
Fix bugs with partialHydration when hydrating with errors

packages/router/__tests__/route-fallback-test.ts

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ describe("future.v7_partialHydration", () => {
157157
it("starts with initialized=true when loaders exist with partial hydration data", async () => {
158158
let parentSpy = jest.fn();
159159
let childSpy = jest.fn();
160-
let router = createRouter({
160+
router = createRouter({
161161
history: createMemoryHistory({ initialEntries: ["/child"] }),
162162
routes: [
163163
{
@@ -191,8 +191,6 @@ describe("future.v7_partialHydration", () => {
191191
expect(router.state.loaderData).toEqual({
192192
"0": "PARENT DATA",
193193
});
194-
195-
router.dispose();
196194
});
197195

198196
it("does not kick off initial data load if errors exist", async () => {
@@ -203,7 +201,7 @@ describe("future.v7_partialHydration", () => {
203201
let parentSpy = jest.fn(() => parentDfd.promise);
204202
let childDfd = createDeferred();
205203
let childSpy = jest.fn(() => childDfd.promise);
206-
let router = createRouter({
204+
router = createRouter({
207205
history: createMemoryHistory({ initialEntries: ["/child"] }),
208206
routes: [
209207
{
@@ -245,7 +243,6 @@ describe("future.v7_partialHydration", () => {
245243
},
246244
});
247245

248-
router.dispose();
249246
consoleWarnSpy.mockReset();
250247
});
251248
});
@@ -522,7 +519,7 @@ describe("future.v7_partialHydration", () => {
522519
.mockImplementation(() => {});
523520
let parentDfd = createDeferred();
524521
let parentSpy = jest.fn(() => parentDfd.promise);
525-
let router = createRouter({
522+
router = createRouter({
526523
history: createMemoryHistory({ initialEntries: ["/child"] }),
527524
routes: [
528525
{
@@ -553,7 +550,122 @@ describe("future.v7_partialHydration", () => {
553550
},
554551
});
555552

556-
router.dispose();
557553
consoleWarnSpy.mockReset();
558554
});
555+
556+
it("does not kick off initial data loads below SSR error boundaries (child throw)", async () => {
557+
let parentCount = 0;
558+
let childCount = 0;
559+
let routes = [
560+
{
561+
id: "parent",
562+
path: "/",
563+
loader: () => `PARENT ${++parentCount}`,
564+
hasErrorBoundary: true,
565+
children: [
566+
{
567+
path: "child",
568+
loader: () => `CHILD ${++childCount}`,
569+
},
570+
],
571+
},
572+
];
573+
574+
// @ts-expect-error
575+
routes[0].loader.hydrate = true;
576+
// @ts-expect-error
577+
routes[0].children[0].loader.hydrate = true;
578+
579+
router = createRouter({
580+
history: createMemoryHistory({ initialEntries: ["/child"] }),
581+
routes,
582+
future: {
583+
v7_partialHydration: true,
584+
},
585+
hydrationData: {
586+
loaderData: {
587+
parent: "PARENT 0",
588+
},
589+
errors: {
590+
// Child threw and bubbled to parent
591+
parent: "CHILD SSR ERROR",
592+
},
593+
},
594+
}).initialize();
595+
expect(router.state).toMatchObject({
596+
initialized: false,
597+
navigation: IDLE_NAVIGATION,
598+
loaderData: {
599+
parent: "PARENT 0",
600+
},
601+
errors: {
602+
parent: "CHILD SSR ERROR",
603+
},
604+
});
605+
await tick();
606+
expect(router.state).toMatchObject({
607+
initialized: true,
608+
navigation: IDLE_NAVIGATION,
609+
loaderData: {
610+
parent: "PARENT 1",
611+
},
612+
errors: {
613+
parent: "CHILD SSR ERROR",
614+
},
615+
});
616+
617+
expect(parentCount).toBe(1);
618+
expect(childCount).toBe(0);
619+
});
620+
621+
it("does not kick off initial data loads at SSR error boundaries (boundary throw)", async () => {
622+
let parentCount = 0;
623+
let childCount = 0;
624+
let routes = [
625+
{
626+
id: "parent",
627+
path: "/",
628+
loader: () => `PARENT ${++parentCount}`,
629+
hasErrorBoundary: true,
630+
children: [
631+
{
632+
path: "child",
633+
loader: () => `CHILD ${++childCount}`,
634+
},
635+
],
636+
},
637+
];
638+
639+
// @ts-expect-error
640+
routes[0].loader.hydrate = true;
641+
// @ts-expect-error
642+
routes[0].children[0].loader.hydrate = true;
643+
644+
router = createRouter({
645+
history: createMemoryHistory({ initialEntries: ["/child"] }),
646+
routes,
647+
future: {
648+
v7_partialHydration: true,
649+
},
650+
hydrationData: {
651+
loaderData: {},
652+
errors: {
653+
// Parent threw
654+
parent: "PARENT SSR ERROR",
655+
},
656+
},
657+
}).initialize();
658+
659+
expect(router.state).toMatchObject({
660+
initialized: true,
661+
navigation: IDLE_NAVIGATION,
662+
loaderData: {},
663+
errors: {
664+
parent: "PARENT SSR ERROR",
665+
},
666+
});
667+
668+
expect(parentCount).toBe(0);
669+
expect(childCount).toBe(0);
670+
});
559671
});

packages/router/router.ts

Lines changed: 73 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -890,33 +890,18 @@ export function createRouter(init: RouterInit): Router {
890890
// were marked for explicit hydration
891891
let loaderData = init.hydrationData ? init.hydrationData.loaderData : null;
892892
let errors = init.hydrationData ? init.hydrationData.errors : null;
893-
let isRouteInitialized = (m: AgnosticDataRouteMatch) => {
894-
// No loader, nothing to initialize
895-
if (!m.route.loader) {
896-
return true;
897-
}
898-
// Explicitly opting-in to running on hydration
899-
if (
900-
typeof m.route.loader === "function" &&
901-
m.route.loader.hydrate === true
902-
) {
903-
return false;
904-
}
905-
// Otherwise, initialized if hydrated with data or an error
906-
return (
907-
(loaderData && loaderData[m.route.id] !== undefined) ||
908-
(errors && errors[m.route.id] !== undefined)
909-
);
910-
};
911-
912893
// If errors exist, don't consider routes below the boundary
913894
if (errors) {
914895
let idx = initialMatches.findIndex(
915896
(m) => errors![m.route.id] !== undefined
916897
);
917-
initialized = initialMatches.slice(0, idx + 1).every(isRouteInitialized);
898+
initialized = initialMatches
899+
.slice(0, idx + 1)
900+
.every((m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors));
918901
} else {
919-
initialized = initialMatches.every(isRouteInitialized);
902+
initialized = initialMatches.every(
903+
(m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors)
904+
);
920905
}
921906
} else {
922907
// Without partial hydration - we're initialized if we were provided any
@@ -1555,7 +1540,7 @@ export function createRouter(init: RouterInit): Router {
15551540
// Short circuit if it's only a hash change and not a revalidation or
15561541
// mutation submission.
15571542
//
1558-
// Ignore on initial page loads because since the initial load will always
1543+
// Ignore on initial page loads because since the initial hydration will always
15591544
// be "same hash". For example, on /page#hash and submit a <Form method="post">
15601545
// which will default to a navigation to /page
15611546
if (
@@ -2067,13 +2052,9 @@ export function createRouter(init: RouterInit): Router {
20672052
});
20682053
});
20692054

2070-
// During partial hydration, preserve SSR errors for routes that don't re-run
2055+
// Preserve SSR errors during partial hydration
20712056
if (future.v7_partialHydration && initialHydration && state.errors) {
2072-
Object.entries(state.errors)
2073-
.filter(([id]) => !matchesToLoad.some((m) => m.route.id === id))
2074-
.forEach(([routeId, error]) => {
2075-
errors = Object.assign(errors || {}, { [routeId]: error });
2076-
});
2057+
errors = { ...state.errors, ...errors };
20772058
}
20782059

20792060
let updatedFetchers = markFetchRedirectsDone();
@@ -4355,20 +4336,18 @@ function normalizeNavigateOptions(
43554336
return { path: createPath(parsedPath), submission };
43564337
}
43574338

4358-
// Filter out all routes below any caught error as they aren't going to
4339+
// Filter out all routes at/below any caught error as they aren't going to
43594340
// render so we don't need to load them
43604341
function getLoaderMatchesUntilBoundary(
43614342
matches: AgnosticDataRouteMatch[],
4362-
boundaryId: string
4343+
boundaryId: string,
4344+
includeBoundary = false
43634345
) {
4364-
let boundaryMatches = matches;
4365-
if (boundaryId) {
4366-
let index = matches.findIndex((m) => m.route.id === boundaryId);
4367-
if (index >= 0) {
4368-
boundaryMatches = matches.slice(0, index);
4369-
}
4346+
let index = matches.findIndex((m) => m.route.id === boundaryId);
4347+
if (index >= 0) {
4348+
return matches.slice(0, includeBoundary ? index + 1 : index);
43704349
}
4371-
return boundaryMatches;
4350+
return matches;
43724351
}
43734352

43744353
function getMatchesToLoad(
@@ -4377,7 +4356,7 @@ function getMatchesToLoad(
43774356
matches: AgnosticDataRouteMatch[],
43784357
submission: Submission | undefined,
43794358
location: Location,
4380-
isInitialLoad: boolean,
4359+
initialHydration: boolean,
43814360
skipActionErrorRevalidation: boolean,
43824361
isRevalidationRequired: boolean,
43834362
cancelledDeferredRoutes: string[],
@@ -4398,13 +4377,26 @@ function getMatchesToLoad(
43984377
let nextUrl = history.createURL(location);
43994378

44004379
// Pick navigation matches that are net-new or qualify for revalidation
4401-
let boundaryId =
4402-
pendingActionResult && isErrorResult(pendingActionResult[1])
4403-
? pendingActionResult[0]
4404-
: undefined;
4405-
let boundaryMatches = boundaryId
4406-
? getLoaderMatchesUntilBoundary(matches, boundaryId)
4407-
: matches;
4380+
let boundaryMatches = matches;
4381+
if (initialHydration && state.errors) {
4382+
// On initial hydration, only consider matches up to _and including_ the boundary.
4383+
// This is inclusive to handle cases where a server loader ran successfully,
4384+
// a child server loader bubbled up to this route, but this route has
4385+
// `clientLoader.hydrate` so we want to still run the `clientLoader` so that
4386+
// we have a complete version of `loaderData`
4387+
boundaryMatches = getLoaderMatchesUntilBoundary(
4388+
matches,
4389+
Object.keys(state.errors)[0],
4390+
true
4391+
);
4392+
} else if (pendingActionResult && isErrorResult(pendingActionResult[1])) {
4393+
// If an action threw an error, we call loaders up to, but not including the
4394+
// boundary
4395+
boundaryMatches = getLoaderMatchesUntilBoundary(
4396+
matches,
4397+
pendingActionResult[0]
4398+
);
4399+
}
44084400

44094401
// Don't revalidate loaders by default after action 4xx/5xx responses
44104402
// when the flag is enabled. They can still opt-into revalidation via
@@ -4426,15 +4418,8 @@ function getMatchesToLoad(
44264418
return false;
44274419
}
44284420

4429-
if (isInitialLoad) {
4430-
if (typeof route.loader !== "function" || route.loader.hydrate) {
4431-
return true;
4432-
}
4433-
return (
4434-
state.loaderData[route.id] === undefined &&
4435-
// Don't re-run if the loader ran and threw an error
4436-
(!state.errors || state.errors[route.id] === undefined)
4437-
);
4421+
if (initialHydration) {
4422+
return shouldLoadRouteOnHydration(route, state.loaderData, state.errors);
44384423
}
44394424

44404425
// Always call the loader on new route instances and pending defer cancellations
@@ -4476,12 +4461,12 @@ function getMatchesToLoad(
44764461
let revalidatingFetchers: RevalidatingFetcher[] = [];
44774462
fetchLoadMatches.forEach((f, key) => {
44784463
// Don't revalidate:
4479-
// - on initial load (shouldn't be any fetchers then anyway)
4464+
// - on initial hydration (shouldn't be any fetchers then anyway)
44804465
// - if fetcher won't be present in the subsequent render
44814466
// - no longer matches the URL (v7_fetcherPersist=false)
44824467
// - was unmounted but persisted due to v7_fetcherPersist=true
44834468
if (
4484-
isInitialLoad ||
4469+
initialHydration ||
44854470
!matches.some((m) => m.route.id === f.routeId) ||
44864471
deletedFetchers.has(key)
44874472
) {
@@ -4561,6 +4546,38 @@ function getMatchesToLoad(
45614546
return [navigationMatches, revalidatingFetchers];
45624547
}
45634548

4549+
function shouldLoadRouteOnHydration(
4550+
route: AgnosticDataRouteObject,
4551+
loaderData: RouteData | null | undefined,
4552+
errors: RouteData | null | undefined
4553+
) {
4554+
// We dunno if we have a loader - gotta find out!
4555+
if (route.lazy) {
4556+
return true;
4557+
}
4558+
4559+
// No loader, nothing to initialize
4560+
if (!route.loader) {
4561+
return false;
4562+
}
4563+
4564+
let hasData = loaderData != null && loaderData[route.id] !== undefined;
4565+
let hasError = errors != null && errors[route.id] !== undefined;
4566+
4567+
// Don't run if we error'd during SSR
4568+
if (!hasData && hasError) {
4569+
return false;
4570+
}
4571+
4572+
// Explicitly opting-in to running on hydration
4573+
if (typeof route.loader === "function" && route.loader.hydrate === true) {
4574+
return true;
4575+
}
4576+
4577+
// Otherwise, run if we're not yet initialized with anything
4578+
return !hasData && !hasError;
4579+
}
4580+
45644581
function isNewLoader(
45654582
currentLoaderData: RouteData,
45664583
currentMatch: AgnosticDataRouteMatch,

0 commit comments

Comments
 (0)