Skip to content

fix(router): drop the reload id when a fetcher action is aborted mid-revalidation - #15365

Merged
brophdawg11 merged 3 commits into
remix-run:mainfrom
afairbrother:af-fetch-reload-ids-abort
Aug 3, 2026
Merged

brophdawg11 merged 3 commits into
remix-run:mainfrom
afairbrother:af-fetch-reload-ids-abort

Conversation

@afairbrother

Copy link
Copy Markdown
Contributor

handleFetcherAction returns early on an aborted signal one line above its own fetchReloadIds.delete(key), so an abort landing between "action resolved" and "revalidation loaders settled" leaves the reload id behind:

if (abortController.signal.aborted) {
  return;
}

fetchReloadIds.delete(key);
fetchControllers.delete(key);

The aborted fetcher then goes idle, and updateState prunes idle mounted fetchers from state.fetchers with a bare state.fetchers.delete(key) that doesn't touch the aux maps. The next call into abortStaleFetchLoads — or markFetchersDone / markFetchRedirectsDone — iterates fetchReloadIds, finds the stale key, and throws invariant(fetcher, "Expected fetcher: " + key).

It throws out of a promise chain, so no error boundary catches it. It surfaces as an unhandled rejection and the in-flight navigation dies.

Reproduction

Ordinary usage, no unstable_ APIs — either of:

  • fetcher.submit() then fetcher.load() on the same key while the submission's revalidation is in flight
  • fetcher.reset() during that same window

Those two are asymmetric, which is the useful detail. A resubmit is safe: it re-enters handleFetcherAction, which overwrites the same key and cleans it up on its own completion. A load() goes through handleFetcherLoader, which never touches fetchReloadIds — so nothing collects the orphan the aborted action left behind. fetchControllers doesn't have this problem because abortFetcher deletes from it as part of aborting; fetchReloadIds has no equivalent self-cleaning path, and that asymmetry is the bug.

Found on 7.15.1, via a fetcher reset landing during a post-save navigation. The relevant code is byte-identical on 7.18.2 and 8.3.0.

The fix

Hoist fetchReloadIds.delete(key) above the aborted bail-out. One line moved — it covers every path that can abort during post-action revalidation, rather than hardening each reader individually.

Two tests in __tests__/router/fetchers-test.ts, one per repro above. Both fail on main with Expected fetcher: key and pass with the change.

One point for review

There's precedent for fixing this from the reader side instead — #12049 / #12050 / #12054 dropped the invariant in abortStaleFetchLoads for the sibling Expected fetch controller error. The equivalent here would be to skip keys with no fetcher and delete the entry on the miss.

I went with the writer side because the stale entry is the defect itself, and this stops it being created rather than tolerating it at three read sites. Happy to switch if you'd rather the two stayed consistent.

…revalidation

`handleFetcherAction` records a `fetchReloadIds` entry once the action
resolves and post-action revalidation begins, and deletes it when that
revalidation completes. The aborted bail-out sits one line above that
delete, so any abort landing in the window leaves the entry behind.

An aborted fetcher then goes idle and is pruned from `state.fetchers` by
`updateState`, which for a still-mounted fetcher uses a bare Map delete
that skips the auxiliary maps. The next `abortStaleFetchLoads` or
`markFetchersDone` caller iterates `fetchReloadIds`, finds the orphaned
key, and throws `Expected fetcher: <key>`. It surfaces as an unhandled
rejection, so no error boundary catches it and the in-flight navigation
dies.

Moving the delete above the bail-out covers every abort path at once.
`fetchControllers` needs no equivalent change: `abortFetcher` deletes
from it as part of aborting, so it is self-cleaning on this path, which
is why the original code could skip it on the early return.
`fetchReloadIds` has no such path — its only other deletion site is
`deleteFetcher`. That asymmetry is the bug.

The non-aborted path is unchanged: there is no `await` between the
line's old and new position, only the `if` test and a
`removeEventListener`, neither of which reads `fetchReloadIds`.

Adds two tests, both failing before this change:

- `submit()` then `load()` on the same key, with no `reset()` involved
- `reset()` during post-action revalidation
@afairbrother
afairbrother force-pushed the af-fetch-reload-ids-abort branch from 142b7d9 to 44de9d3 Compare August 3, 2026 13:41
@brophdawg11 brophdawg11 self-assigned this Aug 3, 2026
Comment on lines 2954 to 2961
// Drop this before the aborted bail-out below. An aborted fetcher goes
// idle and is pruned from state.fetchers, and abortStaleFetchLoads
// invariants on a fetcher existing for every key still in fetchReloadIds
fetchReloadIds.delete(key);

if (abortController.signal.aborted) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there may be a scenario where a second fetcher.submit overtakes a in-flight slow reload and we don't want to blindly delete here since we'd be deleting the newer reload id. I think we may want to leave the existing deletion in place and just add a conditional deletion to the aborted path:

Suggested change
// Drop this before the aborted bail-out below. An aborted fetcher goes
// idle and is pruned from state.fetchers, and abortStaleFetchLoads
// invariants on a fetcher existing for every key still in fetchReloadIds
fetchReloadIds.delete(key);
if (abortController.signal.aborted) {
return;
}
if (abortController.signal.aborted) {
// Drop this before the aborted bail-out below. An aborted fetcher goes
// idle and is pruned from state.fetchers, and abortStaleFetchLoads
// invariants on a fetcher existing for every key still in fetchReloadIds
if (fetchReloadIds.get(key) === loadId) {
fetchReloadIds.delete(key);
}
return;
}

Clarified the issue with `handleFetcherAction` and its handling of aborted fetchers, which led to unhandled rejections during navigation.

@brophdawg11 brophdawg11 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

✅ CLA Signed

Thanks for signing the Contributor License Agreement.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

✅ Change File Found

One or more change files found.

Type Change
patch Fix Expected fetcher: <key> error thrown on navigation when a fetcher is aborted during its post-action revalidation

@brophdawg11
brophdawg11 merged commit db19184 into remix-run:main Aug 3, 2026
14 checks passed
@ryanflorence ryanflorence mentioned this pull request Aug 3, 2026
Comment thread contributors.yml
- adil62
- adriananin
- adrienharnay
- afairbrother

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ommmgggg 🥳

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Hello there,

We recently published version 8.3.1 which includes this pull request. If you'd like to take it for a test run please try it out and let us know what you think!

Thanks!

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.

3 participants