diff --git a/.changeset/spotty-papayas-remember.md b/.changeset/spotty-papayas-remember.md new file mode 100644 index 00000000000..f8d2f073a3f --- /dev/null +++ b/.changeset/spotty-papayas-remember.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +Fix bug with `clientLoader.hydrate` in a layout route when hydrating with bubbled errors diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 44bc613486d..700cc386cac 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -846,6 +846,121 @@ test.describe("Client Data", () => { expect(html).not.toMatch("Should not see me"); console.error = _consoleError; }); + + test("bubbled server loader errors are persisted for hydrating routes", async ({ + page, + }) => { + let _consoleError = console.error; + console.error = () => {}; + appFixture = await createAppFixture( + await createFixture( + { + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.tsx": js` + import { json } from '@remix-run/node' + import { Outlet, useLoaderData, useRouteLoaderData, useRouteError } from '@remix-run/react' + + export function loader() { + return json({ message: 'Parent Server Loader'}); + } + + export async function clientLoader({ serverLoader }) { + console.log('running parent client loader') + // Need a small delay to ensure we capture the server-rendered + // fallbacks for assertions + await new Promise(r => setTimeout(r, 100)); + let data = await serverLoader(); + return { message: data.message + " (mutated by client)" }; + } + + clientLoader.hydrate = true; + + export default function Component() { + let data = useLoaderData(); + return ( + <> +
{data.message}
+{data?.message}
+{error?.data?.message}
+ > + ); + } + `, + "app/routes/parent.child.tsx": js` + import { json } from '@remix-run/node' + import { useRouteError, useLoaderData } from '@remix-run/react' + + export function loader() { + throw json({ message: 'Child Server Error'}); + } + + export function clientLoader() { + console.log('running child client loader') + return "Should not see me"; + } + + clientLoader.hydrate = true; + + export default function Component() { + let data = useLoaderData() + return ( + <> +Should not see me
+{data}
; + > + ); + } + `, + }, + }, + ServerMode.Development // Avoid error sanitization + ), + ServerMode.Development // Avoid error sanitization + ); + let app = new PlaywrightFixture(appFixture, page); + + let logs: string[] = []; + page.on("console", (msg) => logs.push(msg.text())); + + await app.goto("/parent/child", false); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Error"); + expect(html).not.toMatch("Should not see me"); + + // Ensure we hydrate and remain on the boundary + await page.waitForSelector( + ":has-text('Parent Server Loader (mutated by client)')" + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Error"); + expect(html).not.toMatch("Should not see me"); + + expect(logs).toEqual([ + expect.stringContaining("Download the React DevTools"), + "running parent client loader", + ]); + + console.error = _consoleError; + }); }); test.describe("clientLoader - lazy route module", () => { diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 0cd09619129..90675e41f07 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -354,10 +354,13 @@ export function createClientRoutes( // On the first call, resolve with the server result if (isHydrationRequest) { + if (initialData !== undefined) { + return initialData; + } if (initialError !== undefined) { throw initialError; } - return initialData; + return null; } // Call the server loader for client-side navigations