Skip to content

isHydrationRequest not cleared when hydration navigation is aborted#14871

Open
nowells wants to merge 2 commits intoremix-run:mainfrom
nowells:client-loader-hydration-race-condition-bug
Open

isHydrationRequest not cleared when hydration navigation is aborted#14871
nowells wants to merge 2 commits intoremix-run:mainfrom
nowells:client-loader-hydration-race-condition-bug

Conversation

@nowells
Copy link
Contributor

@nowells nowells commented Mar 13, 2026

isHydrationRequest not cleared when hydration navigation is aborted, causing serverLoader() to return stale SSR data

What version of React Router are you using?

7.9.3

Steps to Reproduce

Failing integration test: See integration/bug-report-test.ts in this PR's branch.

The test:

  1. Creates a /search route with clientLoader.hydrate = true that reads ?q= from the URL
  2. The clientLoader does async work before calling serverLoader() (simulating real-world patterns like cache seeding). A firstCall flag ensures only the hydration invocation is slow — the subsequent PUSH invocation runs immediately.
  3. SSRs the page at /search?q=initial, then clicks a link to /search?q=updated before hydration completes
  4. Asserts the page shows "updated" — but it shows "initial" (stale SSR data)

To run: pnpm test:integration bug-report --project chromium

Expected Behavior

When the hydration POP navigation is aborted by a new PUSH navigation, serverLoader() in the PUSH navigation's loaders should fetch fresh data from the server.

Actual Behavior

serverLoader() returns the original SSR initialData (for the wrong URL) because the isHydrationRequest closure variable was never cleared.

Root Cause Analysis

In createClientRoutes (lib/dom/ssr/routes.tsx), each route with hydrate = true that matched the initial SSR URL gets a closure variable isHydrationRequest = true. The route's dataRoute.loader uses this flag to decide whether serverLoader() returns initialData (cached SSR data) or calls fetchServerLoader() (network request). The flag is cleared in a finally block after the loader executes.

The problem: when the hydration POP navigation is aborted (via pendingNavigationController.abort() in startNavigation), loaders that haven't completed yet never reach their finally block, so isHydrationRequest stays true. When the new PUSH navigation calls these loaders (second invocation, same closure), serverLoader() sees isHydrationRequest === true and returns initialData — the SSR data for the original URL params, not the navigation target.

Key detail: React Router runs matched route loaders in parallel. During hydration, a clientLoader that just does await serverLoader() completes almost instantly (since serverLoader() returns initialData synchronously), clearing isHydrationRequest before any abort can happen. The bug only manifests when the clientLoader does async work before calling serverLoader() (e.g., cache operations, analytics, initialization) — keeping the loader pending long enough for the hydration POP to be aborted while isHydrationRequest is still true.

Timeline:

React renders         → DOM ready, but router.state.initialized = false
useLayoutEffect       → router.initialize() → startNavigation(POP, {initialHydration: true})
                        ... hydration loaders start (parallel) ...
                        ... clientLoader does async work before serverLoader() ...
User clicks link      → startNavigation(PUSH, /search?q=updated)
                        → pendingNavigationController.abort()  // aborts hydration POP
                        → isHydrationRequest still true (finally block never reached)
PUSH loader runs      → serverLoader() checks isHydrationRequest === true
(2nd invocation)        → hasInitialData === true (same route matched SSR URL)
                        → returns initialData ({ query: "initial" }), not fresh data

Evidence from instrumented build (in our production app):

[startNavigation] action=POP  initialHydration=true  abortingPending=false initialized=false
[startNavigation] action=PUSH initialHydration=false abortingPending=true  initialized=false
[loader] route=file-browser url=?expanded=data isHydrationRequest=true  // WRONG — should be false
[serverLoader] HYDRATION-PATH — returning initialData instead of fetching

Suggested Fix

When the hydration navigation is aborted, clear isHydrationRequest for all routes — not just the ones whose loaders completed. Options:

  1. On abort, iterate routes and clear the flag:
    When pendingNavigationController.abort() fires for an initialHydration navigation, set isHydrationRequest = false for all hydrating routes.

  2. Check URL in the loader:
    In dataRoute.loader, compare the request URL to initialState.location — if they differ, clear isHydrationRequest before calling serverLoader().

  3. Use router state instead of closure:
    Replace the isHydrationRequest closure with a check against router.state.initialized or similar, which is always up-to-date.

Option 1 is the most direct fix. Option 3 is the cleanest long-term.

diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx
index 0c1682c25..426a5778f 100644
--- a/packages/react-router/lib/dom/ssr/routes.tsx
+++ b/packages/react-router/lib/dom/ssr/routes.tsx
@@ -343,7 +343,11 @@ export function createClientRoutes(
         { request, params, context, unstable_pattern }: LoaderFunctionArgs,
         singleFetch?: unknown,
       ) => {
-        try {
+        // Capture and clear immediately so that if this call is aborted
+        // mid-flight, subsequent calls won't see a stale `true` value
+        let _isHydrationRequest = isHydrationRequest;
+        isHydrationRequest = false;
+
         let result = await prefetchStylesAndCallHandler(async () => {
           invariant(
             routeModule,
@@ -363,7 +367,7 @@ export function createClientRoutes(
               preventInvalidServerHandlerCall("loader", route);
 
               // On the first call, resolve with the server result
-                if (isHydrationRequest) {
+              if (_isHydrationRequest) {
                 if (hasInitialData) {
                   return initialData;
                 }
@@ -378,11 +382,6 @@ export function createClientRoutes(
           });
         });
         return result;
-        } finally {
-          // Whether or not the user calls `serverLoader`, we only let this
-          // stick around as true for one loader call
-          isHydrationRequest = false;
-        }
       };
 
       // Let React Router know whether to run this on hydration
diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx
index 865ef8832..ec2c2ad16 100644
--- a/packages/react-router/lib/rsc/browser.tsx
+++ b/packages/react-router/lib/rsc/browser.tsx
@@ -893,7 +893,11 @@ function createRouteFromServerManifest(
     index: match.index,
     loader: match.clientLoader
       ? async (args, singleFetch) => {
-          try {
+          // Capture and clear immediately so that if this call is aborted
+          // mid-flight, subsequent calls won't see a stale `true` value
+          let _isHydrationRequest = isHydrationRequest;
+          isHydrationRequest = false;
+
           let result = await match.clientLoader!({
             ...args,
             serverLoader: () => {
@@ -903,7 +907,7 @@ function createRouteFromServerManifest(
                 match.hasLoader,
               );
               // On the first call, resolve with the server result
-                if (isHydrationRequest) {
+              if (_isHydrationRequest) {
                 if (hasInitialData) {
                   return initialData;
                 }
@@ -915,9 +919,6 @@ function createRouteFromServerManifest(
             },
           });
           return result;
-          } finally {
-            isHydrationRequest = false;
-          }
         }
       : // We always make the call in this RSC world since even if we don't
         // have a `loader` we may need to get the `element` implementation

The above diff allows this failing test to pass, and I believe addresses the intent and fixes the bug

…, causing `serverLoader()` to return stale SSR data`
@changeset-bot
Copy link

changeset-bot bot commented Mar 13, 2026

⚠️ No Changeset found

Latest commit: c78461b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant