Repository navigation
Description
Problem
In React Router 7.18.1 framework development mode, editing a selected route's loader can leave useLoaderData() stale when that route exports shouldRevalidate() { return false; }.
Expected: Hot Data Revalidation (HDR) refreshes the edited loader's data while retaining the document and input state. Subsequent ordinary navigation/revalidation should again respect the authored policy.
Actual: the HDR policy wrapper returns true, then false, then false during the refresh. The browser route-loader handler is not invoked and the old data remains, even though a .data request receives HTTP 200.
Environment and evidence
- Observed in an existing application using react-router 7.18.1, @react-router/dev 7.18.1, and the official Vite 8.1.5 integration.
- Also observed with rsbuild-plugin-react-router 0.7.1, Rsbuild 2.2.6, and Rspack 2.2.4. That comparison is not required to reproduce the policy mechanism.
- Browser instrumentation preserved the route policy's return values. It recorded the wrapper's
true → false → falsesequence, no browser route-loader invocation, an HTTP 200 response, and retained old loader data after the router returned idle. The edited sentinel never appeared within 60 seconds; document and unsaved input state were retained. - The response body was not decoded in that observation, so this report does not claim that the server returned old or malformed data. The official-Vite run recorded no request abort.
Reproduction limit: the public route reduction below has not been launched as a standalone application. The executable Node example in the details was run, but it copies the wrapper and models the call sequence; it does not run React Router, Single Fetch, Vite or a browser, and does not validate an integration fix.
Route reduction for an integration test
In an SSR-enabled React Router framework app using the versions above, use an ordinary root layout with no server loader and this index route:
import { useLoaderData } from "react-router";
export function loader() {
return { revision: "before" };
}
export function shouldRevalidate() {
return false;
}
export default function Index() {
const { revision } = useLoaderData<typeof loader>();
return (
<main>
<p>{revision}</p>
<input aria-label="Unsaved text" />
</main>
);
}- Hydrate the page and enter text in the input.
- Change only the loader's return string from
"before"to"after". - Wait for automatic HDR and inspect both rendered text and decoded loader data.
The desired assertion is after with input/document state retained, followed by ordinary revalidation honoring false. An HTTP 200 or a call to router.revalidate() is insufficient.
Source explanation in 7.18.1
Source links are pinned to the observed version's commit afdf85d3c15448a41017514caca2aca038d3e9ca:
wrapShouldRevalidateForHdrreturns route membership on its first call and immediately marks the override handled. Later calls delegate to authored policy/defaults.- Initial match selection evaluates policy before constructing the data-strategy match. This can consume the override.
- Single Fetch's
matches.somepreflight callsshouldCallHandler(), which can reevaluate policy. - The loader-resolution callback calls
shouldCallHandleragain and returns early when false.
The HDR request exception can still issue a .data request when no route handler was selected. That explains why a successful HTTP request does not prove the route's loader result was applied.
The call-site attribution above is a source-based explanation. Browser instrumentation established returned values and order, not a call stack for each invocation.
Fix direction and regression coverage
The HDR decision needs to survive all policy checks for its intended refresh operation, then stop overriding authored policy. Avoid a permanent override or a shared global flag that could affect unrelated navigation or overlapping refreshes. Moving only the preflight under !ssr is insufficient if initial selection and resolution still consume the same one-shot wrapper twice.
An integration regression should cover an edited loader with authored false, an unchanged route excluded from HDR, a second loader edit, and ordinary revalidation afterward. Include cancellation/overlapping navigation and client-loader/parent-child cases so one refresh cannot clear or inherit another's override.
This report concerns repeated policy evaluation within one refresh. #14727 concerns unnecessary HDR for component-only changes; reconnect delivery and cancellation are separate assertions.
Executed policy-only model and recorded output
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.
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:
{
"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"
}
Activity
[-][Bug]: one-shot HDR policy expires before Single Fetch resolves the edited loader[/-][+][Bug]: edited loader data stays stale when shouldRevalidate returns false during HDR[/+]