diff --git a/.changeset/tender-elephants-kneel.md b/.changeset/tender-elephants-kneel.md new file mode 100644 index 00000000000..9d789d99b69 --- /dev/null +++ b/.changeset/tender-elephants-kneel.md @@ -0,0 +1,6 @@ +--- +"@remix-run/react": patch +"@remix-run/server-runtime": patch +--- + +- Change initial hydration route mismatch from a URL check to a matches check to be resistant to URL inconsistenceis diff --git a/packages/remix-react/__tests__/components-test.tsx b/packages/remix-react/__tests__/components-test.tsx index f0a30a3a1b1..0f3c353ba3d 100644 --- a/packages/remix-react/__tests__/components-test.tsx +++ b/packages/remix-react/__tests__/components-test.tsx @@ -293,7 +293,7 @@ describe("", () => { describe("", () => { it("handles empty default export objects from the compiler", () => { window.__remixContext = { - url: "/", + ssrMatches: ["root", "empty"], state: { loaderData: {}, }, diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 2b995662647..a4e51933e97 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -25,7 +25,7 @@ import { initFogOfWar, useFogOFWarDiscovery } from "./fog-of-war"; /* eslint-disable prefer-let/prefer-let */ declare global { var __remixContext: { - url: string; + ssrMatches: string[]; basename?: string; state: HydrationState; criticalCss?: string; @@ -194,29 +194,6 @@ if (import.meta && import.meta.hot) { */ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { if (!router) { - // Hard reload if the path we tried to load is not the current path. - // This is usually the result of 2 rapid back/forward clicks from an - // external site into a Remix app, where we initially start the load for - // one URL and while the JS chunks are loading a second forward click moves - // us to a new URL. Avoid comparing search params because of CDNs which - // can be configured to ignore certain params and only pathname is relevant - // towards determining the route matches. - let initialPathname = window.__remixContext.url; - let hydratedPathname = window.location.pathname; - if ( - initialPathname !== hydratedPathname && - !window.__remixContext.isSpaMode - ) { - let errorMsg = - `Initial URL (${initialPathname}) does not match URL at time of hydration ` + - `(${hydratedPathname}), reloading page...`; - console.error(errorMsg); - window.location.reload(); - // Get out of here so the reload can happen - don't create the router - // since it'll then kick off unnecessary route.lazy() loads - return <>; - } - // When single fetch is enabled, we need to suspend until the initial state // snapshot is decoded into window.__remixContext.state if (window.__remixContext.future.unstable_singleFetch) { @@ -270,6 +247,35 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { window.location, window.__remixContext.basename ); + + // Hard reload if the matches we rendered on the server aren't the matches + // we matched in the client, otherwise we'll try to hydrate without the + // right modules and throw a hydration error, which can put React into an + // infinite hydration loop when hydrating the full `` document. + // This is usually the result of 2 rapid back/forward clicks from an + // external site into a Remix app, where we initially start the load for + // one URL and while the JS chunks are loading a second forward click moves + // us to a new URL. + let ssrMatches = window.__remixContext.ssrMatches; + let hasDifferentSSRMatches = + (initialMatches || []).length !== ssrMatches.length || + !(initialMatches || []).every((m, i) => ssrMatches[i] === m.route.id); + + if (hasDifferentSSRMatches && !window.__remixContext.isSpaMode) { + let ssr = ssrMatches.join(","); + let client = (initialMatches || []).map((m) => m.route.id).join(","); + let errorMsg = + `SSR Matches (${ssr}) do not match client matches (${client}) at ` + + `time of hydration , reloading page...`; + console.error(errorMsg); + + window.location.reload(); + + // Get out of here so the reload can happen - don't create the router + // since it'll then kick off unnecessary route.lazy() loads + return <>; + } + if (initialMatches) { for (let match of initialMatches) { let routeId = match.route.id; diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 620604acd49..6c2576b7d0e 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -515,7 +515,7 @@ async function handleDocumentRequest( staticHandlerContext: context, criticalCss, serverHandoffString: createServerHandoffString({ - url: context.location.pathname, + ssrMatches: context.matches.map((m) => m.route.id), basename: build.basename, criticalCss, future: build.future, @@ -592,7 +592,7 @@ async function handleDocumentRequest( ...entryContext, staticHandlerContext: context, serverHandoffString: createServerHandoffString({ - url: context.location.pathname, + ssrMatches: context.matches.map((m) => m.route.id), basename: build.basename, future: build.future, isSpaMode: build.isSpaMode, diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts index 7328388ac3d..124cb5e0711 100644 --- a/packages/remix-server-runtime/serverHandoff.ts +++ b/packages/remix-server-runtime/serverHandoff.ts @@ -20,7 +20,7 @@ export function createServerHandoffString(serverHandoff: { // we'd end up including duplicate info state?: ValidateShape; criticalCss?: string; - url: string; + ssrMatches: string[]; basename: string | undefined; future: FutureConfig; isSpaMode: boolean;