[Bug]: one-shot HDR policy expires before Single Fetch resolves the edited loader
Author: matthewdavis-oaiCreated Sep 15, 2026Updated Sep 17, 2026
Labelspkg:react-router
## Summary
In React Router 7.18.1 framework development mode, an edited route selected for Hot Data Revalidation (HDR) can keep its old `useLoaderData()` value when its authored `shouldRevalidate` returns `false`.
`wrapShouldRevalidateForHdr` overrides only the first policy invocation. The router evaluates that policy during initial match selection, then Single Fetch evaluates it again during preflight and loader resolution. The same wrapper can therefore return **`true → false → false` before the browser-side route loader is called**. This behavior was observed with the official Vite integration as well as an alternative bundler integration, and the call sequence is supported by the pinned source below.
The issue is the override's lifetime: it needs to survive all relevant policy checks for the intended HDR operation. It must not permanently bypass application policy or affect unrelated concurrent navigation.
## Versions and evidence scope
- Browser observation: React Router and `@react-router/dev` 7.18.1; official Vite 8.1.5 integration. The comparable alternative integration used Rsbuild 2.2.6, Rspack 2.2.4 and `rsbuild-plugin-react-router` 0.7.1.
- Pinned observed-version source: [`[email protected]`, commit `afdf85d3c15448a41017514caca2aca038d3e9ca`](https://github.com/remix-run/react-router/tree/afdf85d3c15448a41017514caca2aca038d3e9ca).
- The relevant wrapper and Single Fetch code are also present in [7.18.3's wrapper](https://github.com/remix-run/react-router/blob/23166dfe7f61323f0d2775af67d2691f9ed0843d/packages/react-router/lib/dom/ssr/routes.tsx#L613-L629) and [preflight](https://github.com/remix-run/react-router/blob/23166dfe7f61323f0d2775af67d2691f9ed0843d/packages/react-router/lib/dom/ssr/single-fetch.tsx#L228-L232). That is source evidence; a 7.18.3 browser reproduction has not been run here.
- The standalone Node reduction below was executed successfully. It models policy calls and the proposed semantics; it does **not** import/run the router or prove a complete integration fix.
- A small public framework route reduction is provided below but has not itself been launched. The real browser observations came from an existing application with an instrumented route policy and browser loader, preserving their return values and authored policy.
## Exact source path in 7.18.1
1. [`createClientRoutesWithHMRRevalidationOptOut`](https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/dom/ssr/routes.tsx#L177-L194) passes the selected route-ID set into route construction. [`wrapShouldRevalidateForHdr`](https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/dom/ssr/routes.tsx#L611-L629) returns membership on its first call and immediately sets `handledRevalidation = true`. Later calls delegate to authored policy/defaults.
2. During match selection, [`router.ts:5413–5429`](https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/router/router.ts#L5413-L5429) calls `shouldRevalidateLoader` to compute `shouldLoad` **before** constructing the `DataStrategyMatch`. This can consume the one-shot `true` before Single Fetch runs.
3. Single Fetch's [`matches.some` preflight](https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/dom/ssr/single-fetch.tsx#L228-L232) calls `m.shouldCallHandler()`. It executes even with `ssr: true`, because the `!ssr` branch comes afterward.
4. [`shouldCallHandler`](https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/router/router.ts#L6377-L6397) calls `shouldRevalidateLoader` again, potentially with a different default. The [central helper](https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/router/router.ts#L5660-L5671) invokes route policy afresh; it does not reuse the initial decision.
5. The [`singleFetchLoaderNavigationStrategy` resolution callback](https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/dom/ssr/single-fetch.tsx#L409-L426) calls `m.shouldCallHandler(defaultShouldRevalidate)` again and returns early when false. No successful result is installed for that route.
6. The [HDR exception](https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/dom/ssr/single-fetch.tsx#L480-L500) can still issue a `.data` request when no route was selected, because a new server loader might exist. Thus HTTP 200 is compatible with no updated loader result being delivered to the browser route.
This is more precise than attributing the first consumed override solely to preflight. The source explains selection → preflight → resolution; browser instrumentation recorded returned values/order, not call stacks for each invocation.
## Observed chronology
Official Vite run, with time relative to the loader edit:
| Observation | Time |
| --- | ---: |
| Updated client routes created, edited route selected for HDR | +117.3 ms |
| Automatic `router.revalidate()` | +118.0 ms |
| Same wrapper returns true / false / false | +119.7 / +120.3 / +121.0 ms |
| `.data` request | +122.6 ms |
| HTTP 200 response | +2030.8 ms |
| Router idle, previous null loader data retained | +2032.0 ms |
No browser route-loader invocation was observed, no request abort was recorded in this Vite run, and the edited sentinel value never appeared before the 60-second timeout. Document and unsaved input state were retained; the router had no reported error. Source bytes were restored exactly. The ignored response body was not decoded, so this does not assert that the server response was null, malformed, or missing the edited data.
In the alternative bundler's ordered control, a post-commit HDR revision-file modification preceded automatic revalidation by approximately **2.067 seconds**. The wrapper again returned true/false/false without a browser loader invocation. That observation does not support a request-before-server-commit explanation for that run. It also recorded aborted requests, which are an additional observation rather than a required condition for this policy failure: the Vite run reproduced without an abort.
These findings do not diagnose the cause of a separate reconnect/cancellation failure. Replayed HDR, cancellation, loader selection and decoded-data application need independent assertions. Since the sentinel was never applied in these runs, observing null after restoration is not evidence of a successful sentinel-to-null transition.
## Minimal framework route to exercise in an integration test
Use an SSR-enabled framework app, with an ordinary root layout without a server loader and this index route:
```tsx
import { useLoaderData } from "react-router";
export function loader() {
return { revision: "before" };
}
export function shouldRevalidate() {
return false;
}
export default function Index() {
const { revision } = useLoaderData();
return (
{revision}
); } ``` After hydration, enter unsaved input, change only the loader's return string to `"after"`, and wait for HDR. Expected: fresh decoded loader data and rendered `after` with document/input state retained. Subsequent ordinary revalidation should once again respect authored `false`. This route is a proposed reduction; the executed standalone policy test follows. ## Proposed implementation direction Give the HDR override an **operation identity and route-ID set**, carried through every policy check belonging to that operation. Apply the override before authored policy at the central decision point; preserve normal authored/default behavior when no HDR operation is attached. The following is illustrative internal code, not an existing API or a complete patch: ```ts type HdrPolicyContext = { readonly needsRevalidation: ReadonlySet; }; function shouldRevalidateLoader(match, args, hdr?: HdrPolicyContext) { if (hdr) { return hdr.needsRevalidation.has(match.route.id); } // Existing authored-policy/default logic remains here. } ``` The HDR initiator must create one immutable context for its selected route generation. Router internals must propagate that same context into initial selection, `DataStrategyMatch.shouldCallHandler`, and the relevant resolution/fetcher paths. Replace the single-call `handledRevalidation` behavior only once this ownership is wired. Ordinary revalidation/navigation receives no HDR context. A later edit gets a distinct context; finishing/canceling an older operation must not clear or acknowledge the newer one. Existing cancellation/commit rules must still reject obsolete results. This likely needs coordinated changes in route construction, router operation metadata, and the development refresh caller. It is not safely implemented as a global boolean in the wrapper. Merely checking `window.__reactRouterHdrActive` would let unrelated concurrent navigation inherit HDR policy, and overlapping refresh callbacks can clear one another's flag. Likewise, forcing policy permanently would override the application after HDR. Memoizing all `shouldRevalidate` results globally would ignore intentionally different `defaultShouldRevalidate` arguments and future operations. Moving the preflight under `!ssr` removes an unnecessary SSR policy invocation but is insufficient by itself: initial selection and resolution still invoke the one-shot wrapper twice. Any smaller proposed fix needs to cover both those calls and preserve the data-strategy API's default-override behavior. ## Acceptance tests - Real official-Vite HDR integration: selected loader with authored `false` updates decoded loader data and UI after a body-only loader edit, retaining document/input state. - Selection, preflight and resolution all honor the same HDR decision, including different policy defaults. Include SSR and SPA/prerender paths where the preflight has a purpose. - An unchanged route not in the HDR set remains opted out, even if its authored policy normally returns true; after HDR, ordinary policy resumes for all routes. - Success, error and cancellation end only their own operation's override. A later edit, overlapping navigation and fetcher revalidation do not inherit or prematurely lose another operation's override. - A second loader edit after the first completes also updates data; no permanently consumed or permanently enabled override. - Client loader and mixed parent/child loader cases work, including changed route ordering that changes which `matches.some` checks execute. - HTTP 200, network cancellation, and a revalidate call are recorded separately from decoded result application. Do not use a reconnect test failure as the oracle for this policy bug. ## Related reports / duplicate check The bounded search on September 15 found no matching report for `wrapShouldRevalidateForHdr`, `shouldCallHandler` + HDR, or `shouldRevalidate` + HDR. This is search coverage, not proof that no duplicate exists. - [#14727](https://github.com/remix-run/react-router/issues/14727) concerns unnecessary HDR for component-only changes. This report concerns an explicitly selected changed loader whose HDR override expires before resolution. - [PR #14953](https://github.com/remix-run/react-router/pull/14953), addressing [#14906](https://github.com/remix-run/react-router/issues/14906), concerns dropped/overlapping refresh work and slow module arrival. Those lifetime races matter when integrating an operation context, but its stated changes do not address repeated policy evaluation inside one operation. - [PR #15437](https://github.com/remix-run/react-router/pull/15437) concerns Node streaming disconnect handling; it is not evidence that this HDR policy failure or reconnect data application is fixed. ## Executed standalone policy reproduction Save the following as `hdr-policy-repro.mjs` and run `node hdr-policy-repro.mjs`. It requires no packages, server, browser, or network. The existing wrapper is copied from the pinned 7.18.1 source with TypeScript annotations removed. The modeled call sites reproduce the wrong decisions; the proposed operation model demonstrates the required policy semantics only. ```js import assert from 'node:assert/strict'; // Policy-only reduction. The existing wrapper is copied from React Router // 7.18.1, routes.tsx:613-629, with TypeScript annotations removed. // https://github.com/remix-run/react-router/blob/afdf85d3c15448a41017514caca2aca038d3e9ca/packages/react-router/lib/dom/ssr/routes.tsx#L613-L629 function wrapShouldRevalidateForHdr(routeId, routeShouldRevalidate, needsRevalidation) { let handledRevalidation = false; return (arg) => { if (!handledRevalidation) { handledRevalidation = true; return needsRevalidation.has(routeId); } return routeShouldRevalidate ? routeShouldRevalidate(arg) : arg.defaultShouldRevalidate; }; } // This models the three policy call sites, not a running React Router: // router.ts:5417 (selection), single-fetch.tsx:230 (preflight), :419 (resolution). function tracePolicy(shouldRevalidate) { const args = { defaultShouldRevalidate: true }; const selection = shouldRevalidate(args); const preflight = shouldRevalidate(args); const resolution = shouldRevalidate({ ...args, defaultShouldRevalidate: true }); let browserLoaderCalls = 0; if (resolution) browserLoaderCalls += 1; return { selection, preflight, resolution, browserLoaderCalls }; } const authoredPolicy = () => false; const existing = tracePolicy( wrapShouldRevalidateForHdr('index', authoredPolicy, new Set(['index'])), ); assert.deepEqual(existing, { selection: true, preflight: false, resolution: false, browserLoaderCalls: 0, }); // Proposed semantics, not a patch to the installed package. An immutable // operation context must be carried by router internals into EVERY policy call. // Unrelated operations receive no context. No global "HDR active" switch. function policyForOperation(routeId, authored, args, hdrContext) { if (hdrContext) return hdrContext.needsRevalidation.has(routeId); return authored ? authored(args) : args.defaultShouldRevalidate; } const hdrOperation = { needsRevalidation: new Set(['index']) }; const fixedModel = tracePolicy(args => policyForOperation('index', authoredPolicy, args, hdrOperation), ); assert.deepEqual(fixedModel, { selection: true, preflight: true, resolution: true, browserLoaderCalls: 1, }); assert.equal(policyForOperation('index', authoredPolicy, { defaultShouldRevalidate: true }), false); assert.equal(policyForOperation('unchanged', () => true, { defaultShouldRevalidate: true }, hdrOperation), false); assert.equal(policyForOperation('index', authoredPolicy, { defaultShouldRevalidate: false }, hdrOperation), true); assert.equal(policyForOperation('index', authoredPolicy, { defaultShouldRevalidate: true }, { needsRevalidation: new Set(['other']) }), false); console.log(JSON.stringify({ existing, proposedOperationModel: fixedModel, scope: 'policy-only; no actual router, network, HMR, or cancellation integration' }, null, 2)); ``` Observed standalone output: ```json { "existing": { "selection": true, "preflight": false, "resolution": false, "browserLoaderCalls": 0 }, "proposedOperationModel": { "selection": true, "preflight": true, "resolution": true, "browserLoaderCalls": 1 }, "scope": "policy-only; no actual router, network, HMR, or cancellation integration" } ```Source: remix-run/react-router