diff --git a/.changeset/partial-hydration-errors.md b/.changeset/partial-hydration-errors.md
new file mode 100644
index 0000000000..1d9ea20131
--- /dev/null
+++ b/.changeset/partial-hydration-errors.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/router": patch
+---
+
+Preserve hydrated errors during partial hydration runs
diff --git a/packages/react-router-dom/__tests__/partial-hydration-test.tsx b/packages/react-router-dom/__tests__/partial-hydration-test.tsx
index 9a2723363f..dac2f3d5d9 100644
--- a/packages/react-router-dom/__tests__/partial-hydration-test.tsx
+++ b/packages/react-router-dom/__tests__/partial-hydration-test.tsx
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom";
-import { render, screen, waitFor } from "@testing-library/react";
+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";
@@ -650,4 +650,89 @@ function testPartialHydration(
"
`);
});
+
+ it("preserves hydrated errors for non-hydrating loaders", async () => {
+ let dfd = createDeferred();
+ let rootSpy: LoaderFunction = jest.fn(() => dfd.promise);
+ rootSpy.hydrate = true;
+
+ let indexSpy = jest.fn();
+
+ let router = createTestRouter(
+ [
+ {
+ id: "root",
+ path: "/",
+ loader: rootSpy,
+ Component() {
+ let data = useLoaderData() as string;
+ return (
+ <>
+
{`Home - ${data}`}
+
+ >
+ );
+ },
+ children: [
+ {
+ id: "index",
+ index: true,
+ loader: indexSpy,
+ Component() {
+ let data = useLoaderData() as string;
+ return {`Index - ${data}`}
;
+ },
+ ErrorBoundary() {
+ let error = useRouteError() as string;
+ return {error}
;
+ },
+ },
+ ],
+ },
+ ],
+ {
+ hydrationData: {
+ loaderData: {
+ root: "HYDRATED ROOT",
+ },
+ errors: {
+ index: "INDEX ERROR",
+ },
+ },
+ future: {
+ v7_partialHydration: true,
+ },
+ }
+ );
+ let { container } = render();
+
+ expect(getHtml(container)).toMatchInlineSnapshot(`
+ "
+
+ Home - HYDRATED ROOT
+
+
+ INDEX ERROR
+
+
"
+ `);
+
+ expect(router.state.initialized).toBe(false);
+
+ await act(() => dfd.resolve("UPDATED ROOT"));
+
+ expect(getHtml(container)).toMatchInlineSnapshot(`
+ "
+
+ Home - UPDATED ROOT
+
+
+ INDEX ERROR
+
+
"
+ `);
+
+ expect(rootSpy).toHaveBeenCalledTimes(1);
+ expect(indexSpy).not.toHaveBeenCalled();
+ });
}
diff --git a/packages/router/router.ts b/packages/router/router.ts
index 1c1b04b4aa..bdea4b0ccf 100644
--- a/packages/router/router.ts
+++ b/packages/router/router.ts
@@ -1718,7 +1718,7 @@ export function createRouter(init: RouterInit): Router {
// preserving any new action data or existing action data (in the case of
// a revalidation interrupting an actionReload)
// If we have partialHydration enabled, then don't update the state for the
- // initial data load since iot's not a "navigation"
+ // initial data load since it's not a "navigation"
if (
!isUninterruptedRevalidation &&
(!future.v7_partialHydration || !initialHydration)
@@ -1835,6 +1835,15 @@ export function createRouter(init: RouterInit): Router {
});
});
+ // During partial hydration, preserve SSR errors for routes that don't re-run
+ if (future.v7_partialHydration && initialHydration && state.errors) {
+ Object.entries(state.errors)
+ .filter(([id]) => !matchesToLoad.some((m) => m.route.id === id))
+ .forEach(([routeId, error]) => {
+ errors = Object.assign(errors || {}, { [routeId]: error });
+ });
+ }
+
let updatedFetchers = markFetchRedirectsDone();
let didAbortFetchLoads = abortStaleFetchLoads(pendingNavigationLoadId);
let shouldUpdateFetchers =