Skip to content

Commit

Permalink
Change hydration check from URL to matches (#9695)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 authored Jul 24, 2024
1 parent 2c8eecd commit cfc0c6f
Show file tree
Hide file tree
Showing 5 changed files with 40 additions and 28 deletions.
6 changes: 6 additions & 0 deletions .changeset/tender-elephants-kneel.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/remix-react/__tests__/components-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ describe("<RemixServer>", () => {
describe("<RemixBrowser>", () => {
it("handles empty default export objects from the compiler", () => {
window.__remixContext = {
url: "/",
ssrMatches: ["root", "empty"],
state: {
loaderData: {},
},
Expand Down
54 changes: 30 additions & 24 deletions packages/remix-react/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 `<html>` 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;
Expand Down
4 changes: 2 additions & 2 deletions packages/remix-server-runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/remix-server-runtime/serverHandoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function createServerHandoffString<T>(serverHandoff: {
// we'd end up including duplicate info
state?: ValidateShape<T, HydrationState>;
criticalCss?: string;
url: string;
ssrMatches: string[];
basename: string | undefined;
future: FutureConfig;
isSpaMode: boolean;
Expand Down

0 comments on commit cfc0c6f

Please sign in to comment.