Skip to content

Commit 998558f

Browse files
committed
Fix partial hydration bugs when hydrating with errors
1 parent eeb86ab commit 998558f

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
@@ -894,33 +894,18 @@ export function createRouter(init: RouterInit): Router {
894894
// were marked for explicit hydration
895895
let loaderData = init.hydrationData ? init.hydrationData.loaderData : null;
896896
let errors = init.hydrationData ? init.hydrationData.errors : null;
897-
let isRouteInitialized = (m: AgnosticDataRouteMatch) => {
898-
// No loader, nothing to initialize
899-
if (!m.route.loader) {
900-
return true;
901-
}
902-
// Explicitly opting-in to running on hydration
903-
if (
904-
typeof m.route.loader === "function" &&
905-
m.route.loader.hydrate === true
906-
) {
907-
return false;
908-
}
909-
// Otherwise, initialized if hydrated with data or an error
910-
return (
911-
(loaderData && loaderData[m.route.id] !== undefined) ||
912-
(errors && errors[m.route.id] !== undefined)
913-
);
914-
};
915-
916897
// If errors exist, don't consider routes below the boundary
917898
if (errors) {
918899
let idx = initialMatches.findIndex(
919900
(m) => errors![m.route.id] !== undefined
920901
);
921-
initialized = initialMatches.slice(0, idx + 1).every(isRouteInitialized);
902+
initialized = initialMatches
903+
.slice(0, idx + 1)
904+
.every((m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors));
922905
} else {
923-
initialized = initialMatches.every(isRouteInitialized);
906+
initialized = initialMatches.every(
907+
(m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors)
908+
);
924909
}
925910
} else {
926911
// Without partial hydration - we're initialized if we were provided any
@@ -1559,7 +1544,7 @@ export function createRouter(init: RouterInit): Router {
15591544
// Short circuit if it's only a hash change and not a revalidation or
15601545
// mutation submission.
15611546
//
1562-
// Ignore on initial page loads because since the initial load will always
1547+
// Ignore on initial page loads because since the initial hydration will always
15631548
// be "same hash". For example, on /page#hash and submit a <Form method="post">
15641549
// which will default to a navigation to /page
15651550
if (
@@ -2073,13 +2058,9 @@ export function createRouter(init: RouterInit): Router {
20732058
});
20742059
});
20752060

2076-
// During partial hydration, preserve SSR errors for routes that don't re-run
2061+
// Preserve SSR errors during partial hydration
20772062
if (future.v7_partialHydration && initialHydration && state.errors) {
2078-
Object.entries(state.errors)
2079-
.filter(([id]) => !matchesToLoad.some((m) => m.route.id === id))
2080-
.forEach(([routeId, error]) => {
2081-
errors = Object.assign(errors || {}, { [routeId]: error });
2082-
});
2063+
errors = { ...state.errors, ...errors };
20832064
}
20842065

20852066
let updatedFetchers = markFetchRedirectsDone();
@@ -4352,20 +4333,18 @@ function normalizeNavigateOptions(
43524333
return { path: createPath(parsedPath), submission };
43534334
}
43544335

4355-
// Filter out all routes below any caught error as they aren't going to
4336+
// Filter out all routes at/below any caught error as they aren't going to
43564337
// render so we don't need to load them
43574338
function getLoaderMatchesUntilBoundary(
43584339
matches: AgnosticDataRouteMatch[],
4359-
boundaryId: string
4340+
boundaryId: string,
4341+
includeBoundary = false
43604342
) {
4361-
let boundaryMatches = matches;
4362-
if (boundaryId) {
4363-
let index = matches.findIndex((m) => m.route.id === boundaryId);
4364-
if (index >= 0) {
4365-
boundaryMatches = matches.slice(0, index);
4366-
}
4343+
let index = matches.findIndex((m) => m.route.id === boundaryId);
4344+
if (index >= 0) {
4345+
return matches.slice(0, includeBoundary ? index + 1 : index);
43674346
}
4368-
return boundaryMatches;
4347+
return matches;
43694348
}
43704349

43714350
function getMatchesToLoad(
@@ -4374,7 +4353,7 @@ function getMatchesToLoad(
43744353
matches: AgnosticDataRouteMatch[],
43754354
submission: Submission | undefined,
43764355
location: Location,
4377-
isInitialLoad: boolean,
4356+
initialHydration: boolean,
43784357
skipActionErrorRevalidation: boolean,
43794358
isRevalidationRequired: boolean,
43804359
cancelledDeferredRoutes: string[],
@@ -4395,13 +4374,26 @@ function getMatchesToLoad(
43954374
let nextUrl = history.createURL(location);
43964375

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

44064398
// Don't revalidate loaders by default after action 4xx/5xx responses
44074399
// when the flag is enabled. They can still opt-into revalidation via
@@ -4423,15 +4415,8 @@ function getMatchesToLoad(
44234415
return false;
44244416
}
44254417

4426-
if (isInitialLoad) {
4427-
if (typeof route.loader !== "function" || route.loader.hydrate) {
4428-
return true;
4429-
}
4430-
return (
4431-
state.loaderData[route.id] === undefined &&
4432-
// Don't re-run if the loader ran and threw an error
4433-
(!state.errors || state.errors[route.id] === undefined)
4434-
);
4418+
if (initialHydration) {
4419+
return shouldLoadRouteOnHydration(route, state.loaderData, state.errors);
44354420
}
44364421

44374422
// Always call the loader on new route instances and pending defer cancellations
@@ -4473,12 +4458,12 @@ function getMatchesToLoad(
44734458
let revalidatingFetchers: RevalidatingFetcher[] = [];
44744459
fetchLoadMatches.forEach((f, key) => {
44754460
// Don't revalidate:
4476-
// - on initial load (shouldn't be any fetchers then anyway)
4461+
// - on initial hydration (shouldn't be any fetchers then anyway)
44774462
// - if fetcher won't be present in the subsequent render
44784463
// - no longer matches the URL (v7_fetcherPersist=false)
44794464
// - was unmounted but persisted due to v7_fetcherPersist=true
44804465
if (
4481-
isInitialLoad ||
4466+
initialHydration ||
44824467
!matches.some((m) => m.route.id === f.routeId) ||
44834468
deletedFetchers.has(key)
44844469
) {
@@ -4558,6 +4543,38 @@ function getMatchesToLoad(
45584543
return [navigationMatches, revalidatingFetchers];
45594544
}
45604545

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

0 commit comments

Comments
 (0)