Repository navigation
A 4xx action that interrupts the revalidation of an earlier successful action leaves loader data stale #15617
Description
What happens
Submission A succeeds and its loader revalidation starts. Before that revalidation
commits, submission B is made and its action returns (or, for a navigation, throws) a
4xx/5xx response. B cancels A's in-flight revalidation, and then B's own revalidation is
skipped because of the "do not revalidate after a 4xx/5xx action" default. Nothing ever
loads the data from after A's mutation: loaderData keeps the values from before A until
some later navigation or revalidation happens to reload the routes.
It reproduces with two overlapping navigation submissions and with two overlapping fetcher
submissions, with no shouldRevalidate on any route and no call-site
defaultShouldRevalidate.
Reproduction
A router-level test in the style of packages/react-router/__tests__/router/, using the
existing setup helper. Save as
packages/react-router/__tests__/router/interrupted-revalidation-4xx-test.ts. The first
two tests fail on current behaviour; the third is a control that passes and must keep
passing.
import { cleanup, setup } from "./utils/data-router-setup";
import { createFormData } from "./utils/utils";
function initializeTest() {
return setup({
routes: [
{
path: "",
id: "root",
loader: true,
children: [
{
path: "/page",
id: "page",
loader: true,
action: true,
},
],
},
],
initialEntries: ["/page"],
hydrationData: {
loaderData: { root: "ROOT 0", page: "PAGE 0" },
},
});
}
describe("a 4xx submission interrupting the revalidation of a successful one", () => {
afterEach(() => cleanup());
it("still revalidates (navigation submissions)", async () => {
let t = initializeTest();
// A succeeds and its revalidation starts
let A = await t.navigate("/page", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
await A.actions.page.resolve("A ACTION");
expect(A.loaders.root.stub).toHaveBeenCalledTimes(1);
expect(A.loaders.page.stub).toHaveBeenCalledTimes(1);
// B interrupts A's revalidation and is refused with a 400
let B = await t.navigate("/page", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
expect(A.loaders.root.signal.aborted).toBe(true);
expect(A.loaders.page.signal.aborted).toBe(true);
await B.actions.page.resolve(new Response("B REFUSED", { status: 400 }));
// A's mutation happened, so the loaders must still run once more
await B.loaders.root.resolve("ROOT 1");
await B.loaders.page.resolve("PAGE 1");
expect({
rootLoaderCalls: B.loaders.root.stub.mock.calls.length,
pageLoaderCalls: B.loaders.page.stub.mock.calls.length,
navigation: t.router.state.navigation.state,
actionData: t.router.state.actionData,
loaderData: t.router.state.loaderData,
}).toEqual({
rootLoaderCalls: 1,
pageLoaderCalls: 1,
navigation: "idle",
actionData: { page: "B REFUSED" },
loaderData: { root: "ROOT 1", page: "PAGE 1" },
});
});
it("still revalidates (fetcher submissions)", async () => {
let t = initializeTest();
// A succeeds and its revalidation starts
let A = await t.fetch("/page", "a", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
await A.actions.page.resolve("A ACTION");
expect(A.loaders.root.stub).toHaveBeenCalledTimes(1);
expect(A.loaders.page.stub).toHaveBeenCalledTimes(1);
// B is refused with a 400 while A's revalidation is still in flight
let B = await t.fetch("/page", "b", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
await B.actions.page.resolve(new Response("B REFUSED", { status: 400 }));
// A's revalidation was aborted when B's (empty) revalidation landed
expect(A.loaders.root.signal.aborted).toBe(true);
expect(A.loaders.page.signal.aborted).toBe(true);
await A.loaders.root.resolve("ROOT 1 (A)");
await A.loaders.page.resolve("PAGE 1 (A)");
// A's mutation happened, so the loaders must still run once more
await B.loaders.root.resolve("ROOT 1");
await B.loaders.page.resolve("PAGE 1");
expect({
rootLoaderCalls: B.loaders.root.stub.mock.calls.length,
pageLoaderCalls: B.loaders.page.stub.mock.calls.length,
fetcherA: t.fetchers.a.state,
fetcherB: t.fetchers.b.state,
fetcherBData: t.fetchers.b.data,
loaderData: t.router.state.loaderData,
}).toEqual({
rootLoaderCalls: 1,
pageLoaderCalls: 1,
fetcherA: "idle",
fetcherB: "idle",
fetcherBData: "B REFUSED",
loaderData: { root: "ROOT 1", page: "PAGE 1" },
});
});
// Control: the rule the ordering exists for. Not part of the defect.
it("does not revalidate after a lone 4xx submission (control)", async () => {
let t = initializeTest();
let A = await t.navigate("/page", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
await A.actions.page.resolve(new Response("A REFUSED", { status: 400 }));
expect(A.loaders.root.stub).not.toHaveBeenCalled();
expect(A.loaders.page.stub).not.toHaveBeenCalled();
expect(t.router.state.navigation.state).toBe("idle");
expect(t.router.state.loaderData).toEqual({
root: "ROOT 0",
page: "PAGE 0",
});
});
});Expected versus actual
Expected, in both failing cases: after B settles, the root and page loaders have run
once more and loaderData is { root: "ROOT 1", page: "PAGE 1" }, because A's action
succeeded and its revalidation never committed.
Actual, in both failing cases: no loader runs for B, A's loaders are aborted, and the
router settles idle with the hydrated values.
- Expected
+ Received
"loaderData": {
- "page": "PAGE 1",
- "root": "ROOT 1",
+ "page": "PAGE 0",
+ "root": "ROOT 0",
},
- "pageLoaderCalls": 1,
- "rootLoaderCalls": 1,
+ "pageLoaderCalls": 0,
+ "rootLoaderCalls": 0,
Result of the run: 2 failed, 1 passed (the control).
Variants seen with the same setup: a navigation whose action throws the 400 behaves the
same for the loaders above the error boundary (they keep the pre-A data). A fetcher whose
action throws the 400 is not affected, because handleFetcherAction returns before it
reaches getMatchesToLoad, so A's revalidation is left to finish.
Cause
All references are to packages/react-router/lib/router/router.ts on main at
dee9ad2dab41889e8fbdc6848b5781255d9f133a.
getMatchesToLoad decides the default for "should this loader run again" in this order
(L5515-L5523):
if (typeof callSiteDefaultShouldRevalidate === "boolean") {
// Use call-site value verbatim if provided
defaultShouldRevalidate = callSiteDefaultShouldRevalidate;
} else if (shouldSkipRevalidation) {
// Skip due to 4xx/5xx action result
defaultShouldRevalidate = false;
} else if (isRevalidationRequired) {
// Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate
defaultShouldRevalidate = true;
}The same order is used for revalidating fetchers at
L5647-L5654.
shouldSkipRevalidation is actionStatus >= 400 of the current submission (L5454).
isRevalidationRequired is the only record that a revalidation is needed. It is set by
interruptActiveLoads() (L3596-L3598, "Every interruption triggers a revalidation"),
which handleAction (L2181) and handleFetcherAction (L2800) call first thing, and it is
cleared when a round commits: in completeNavigation (L1676) and at the end of
handleFetcherAction (L3143).
So in the reproduction:
- A's action succeeds, A's revalidation starts. The flag is
true. - B starts. Navigation case: starting B aborts A's navigation, loaders included. Fetcher
case: A's loads keep running for now. - B's action returns 400.
getMatchesToLoadruns withshouldSkipRevalidationtrue, which
is checked before the flag, so no route is selected. - B's empty round commits. Navigation case:
completeNavigationclears the flag. Fetcher
case:abortStaleFetchLoads(loadId, ...)(L3111) aborts A's older, still in-flight round
and the flag is cleared (L3143).
The revalidation that A's successful mutation required was cancelled, and the only memory
of it was cleared by a round that loaded nothing.
Why swapping the two branches is not the fix
Every submission sets isRevalidationRequired itself, through its own
interruptActiveLoads() call, before its action runs. By the time getMatchesToLoad runs
after an action, the flag is therefore always true. Checking it before
shouldSkipRevalidation would make every 4xx/5xx action revalidate, which removes the skip
rule altogether: a single refused submission must still reload nothing (the control test
above, and the existing "does not revalidate after actions returning 4xx/5xx responses"
tests in should-revalidate-test.ts). The flag cannot tell "set by this submission" apart
from "left over from a round that was cancelled before it committed".
Suggested direction
Offered as a direction, not a patch: keep a separate record that a revalidation required
by a successful mutation was cancelled before its results committed, and let that owed
revalidation take precedence over the 4xx/5xx skip rule. Clear it only when a round that
actually ran the loaders commits, so a lone refused submission still reloads nothing.
Versions and environment
- Found in a fork based on react-router 8.4.0. The decision order,
interruptActiveLoads
and both action handlers involved are unchanged from 8.4.0 in that fork. - Reproduction run on that fork: the test above, ported only for the test runner (Vitest
4.1.11 instead of Jest: one added import line, no semicolons), Node 24.15.0, macOS.
Result: 2 failed, 1 passed, with the diff shown above. - Upstream
mainatdee9ad2dab41889e8fbdc6848b5781255d9f133a(9 October 2026,
packages/react-routerversion 8.4.0): read, not run. The decision order and the lines
cited above are identical there, and the test helpers used (setup,navigate,fetch,
resolve,signal,fetchers) exist with the same shape. - Data router only (
createRouter), no framework mode, no middleware, no custom
dataStrategy, noshouldRevalidate, no call-sitedefaultShouldRevalidate.
Existing reports checked
Searched issues and pull requests of remix-run/react-router on 11 October 2026 for
skipActionErrorRevalidation, isRevalidationRequired, and several phrasings of
"revalidation interrupted by a 4xx action". Nothing found that covers this. Nearest
results, none of them the same defect:
- Data Strategy Configuration #11098 (merged PR) "Data Strategy Configuration": introduced the 4xx/5xx skip behind a flag.
- Stabilize future.unstable_skipActionErrorRevalidation #11769 (merged PR) "Stabilize future.unstable_skipActionErrorRevalidation": stabilised the flag.
- chore: change
v7_skipActionStatusRevalidationtov7_skipActionErrorRevalidation#12253, v7_skipActionStatusRevalidation -> v7_skipActionErrorRevalidation in Docs #12240 (merged PRs): renamed the flag in code and docs. - Add callsite revalidation optout #14542 (merged PR) "Add callsite revalidation optout": added the call-site branch that sits above the skip rule.
- v8_middleware: a root middleware with pre-next() latency makes form submissions run the action twice #15189 (closed issue) "v8_middleware: a root middleware with pre-next() latency makes form submissions run the action twice": mentions
interruptActiveLoads()andisRevalidationRequired, but is about a duplicated action, not a lost revalidation.
Activity
fix(router): preserve mutation revalidation interrupted by 4xx action (…
brophdawg11 commentedon Oct 22, 2026
Potentially related issue: #15618
Authored by an AI agent on behalf of @brophdawg11