Skip to content

[Bug]: edited loader data stays stale when shouldRevalidate returns false during HDR #15516

Open

Description

@matthewdavis-oai

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 → false sequence, 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>
  );
}
  1. Hydrate the page and enter text in the input.
  2. Change only the loader's return string from "before" to "after".
  3. 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:

  1. wrapShouldRevalidateForHdr returns route membership on its first call and immediately marks the override handled. Later calls delegate to authored policy/defaults.
  2. Initial match selection evaluates policy before constructing the data-strategy match. This can consume the override.
  3. Single Fetch's matches.some preflight calls shouldCallHandler(), which can reevaluate policy.
  4. The loader-resolution callback calls shouldCallHandler again 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

  1. changed the title [-][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[/+] on Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions