diff --git a/.changeset/partial-hydration-bubbled-error.md b/.changeset/partial-hydration-bubbled-error.md new file mode 100644 index 0000000000..c0b2bef50d --- /dev/null +++ b/.changeset/partial-hydration-bubbled-error.md @@ -0,0 +1,5 @@ +--- +"@remix-run/router": patch +--- + +Fix a `future.v7_partialHydration` bug that would re-run loaders below the boundary on hydration if SSR loader errors bubbled to a parent boundary diff --git a/packages/router/__tests__/route-fallback-test.ts b/packages/router/__tests__/route-fallback-test.ts index b4afc25419..1d8265be45 100644 --- a/packages/router/__tests__/route-fallback-test.ts +++ b/packages/router/__tests__/route-fallback-test.ts @@ -401,7 +401,7 @@ describe("future.v7_partialHydration", () => { }); }); - it("does not kick off initial data load if errors exist", async () => { + it("does not kick off initial data load if errors exist (parent error)", async () => { let consoleWarnSpy = jest .spyOn(console, "warn") .mockImplementation(() => {}); @@ -457,5 +457,62 @@ describe("future.v7_partialHydration", () => { router.dispose(); consoleWarnSpy.mockReset(); }); + + it("does not kick off initial data load if errors exist (bubbled child error)", async () => { + let consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + let parentDfd = createDeferred(); + let parentSpy = jest.fn(() => parentDfd.promise); + let childDfd = createDeferred(); + let childSpy = jest.fn(() => childDfd.promise); + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/child"] }), + routes: [ + { + path: "/", + loader: parentSpy, + children: [ + { + path: "child", + loader: childSpy, + }, + ], + }, + ], + future: { + v7_partialHydration: true, + }, + hydrationData: { + errors: { + "0": "CHILD ERROR", + }, + loaderData: { + "0": "PARENT DATA", + }, + }, + }); + router.initialize(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(parentSpy).not.toHaveBeenCalled(); + expect(childSpy).not.toHaveBeenCalled(); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: expect.objectContaining({ pathname: "/child" }), + matches: [{ route: { path: "/" } }, { route: { path: "child" } }], + initialized: true, + navigation: IDLE_NAVIGATION, + errors: { + "0": "CHILD ERROR", + }, + loaderData: { + "0": "PARENT DATA", + }, + }); + + router.dispose(); + consoleWarnSpy.mockReset(); + }); }); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index bdea4b0ccf..38d5034cda 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -833,13 +833,21 @@ export function createRouter(init: RouterInit): Router { // were marked for explicit hydration let loaderData = init.hydrationData ? init.hydrationData.loaderData : null; let errors = init.hydrationData ? init.hydrationData.errors : null; - initialized = initialMatches.every( - (m) => - m.route.loader && - m.route.loader.hydrate !== true && - ((loaderData && loaderData[m.route.id] !== undefined) || - (errors && errors[m.route.id] !== undefined)) - ); + let isRouteInitialized = (m: AgnosticDataRouteMatch) => + m.route.loader && + m.route.loader.hydrate !== true && + ((loaderData && loaderData[m.route.id] !== undefined) || + (errors && errors[m.route.id] !== undefined)); + + // If errors exist, don't consider routes below the boundary + if (errors) { + let idx = initialMatches.findIndex( + (m) => errors![m.route.id] !== undefined + ); + initialized = initialMatches.slice(0, idx + 1).every(isRouteInitialized); + } else { + initialized = initialMatches.every(isRouteInitialized); + } } else { // Without partial hydration - we're initialized if we were provided any // hydrationData - which is expected to be complete