Skip to content

fix(router): suppress abort errors in single fetch - #14761

Open
yoni-noma wants to merge 17 commits into
remix-run:mainfrom
yoni-noma:fix/fetcher-abort-errors
Open

yoni-noma wants to merge 17 commits into
remix-run:mainfrom
yoni-noma:fix/fetcher-abort-errors

Conversation

@yoni-noma

@yoni-noma yoni-noma commented Jan 30, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes navigation and fetcher crashes caused by inconsistent abort error shapes (AbortError, TypeError, or undefined) in single-fetch + data strategy + manifest discovery. Normalizes abort detection in one helper (isAbortError) and applies it at the entry points where browser-level aborts can race ahead of AbortController.abort() propagation.

Related: #14203

Background

AbortSignal.aborted is not always true at the moment fetches fail. The browser can terminate fetch() at the network layer (response code 0) before JavaScript's AbortController.abort() runs — most commonly during rapid navigation. In those windows, fetch throws a TypeError("Failed to fetch") while signal.aborted is still false, and the error bubbles to error boundaries as a real failure.

This PR introduces a shared isAbortError helper that detects abort situations across the variants browsers actually produce (AbortError DOMException, Error with name === "AbortError", or TypeError with a known abort message), and routes those through the existing abort suppression paths instead of error boundaries.

Changes

New shared helper

  • packages/react-router/lib/router/abort.ts — isAbortError(error, signal, { allowTypeError }). Strict by signal.aborted first, then AbortError instances. TypeError message matching is opt-in (allowTypeError: true) since it can mask real network failures if applied too broadly.

Data strategy (router.ts)

  • Skip aborted route results in the per-route loop so mergeLoaderData preserves the previous valid data instead of being overwritten with undefined.
  • In the outer callDataStrategy catch, return empty dataResults for abort errors instead of bubbling to root.
  • Reject abortPromise in callLoaderOrAction with a normalized AbortError (using signal.reason when available) so handlers can detect aborts consistently.
  • Suppress abort-related errors in handleFetcherAction / handleFetcherLoader discovery error paths.
  • Defensive null guards in handleFetcherAction, handleFetcherLoader, and the revalidation fetcher result merge — when callDataStrategy skips a result for abort reasons, settle the fetcher with its previous data instead of crashing on invariant(result, ...).
  • Pass abort reasons through abortFetcher(key, reason) so the abort source is visible in signal.reason (helps with the observability gap the original code had).

Single fetch (single-fetch.tsx)

  • fetchAndDecodeViaTurboStream normalizes a TypeError from fetch() into a proper AbortError DOMException when the signal indicates an abort, so downstream consumers see the standard shape.
  • unwrapSingleFetchResultWithFallback — when SingleFetchNoResultError is thrown during a navigation race, reuse existing router.state.loaderData[routeId] if present (mirroring mergeLoaderData's preserve-existing semantics). First-time loads without prior data still throw as before.

Manifest discovery (fog-of-war.ts)

  • One-time retry in fetchAndApplyManifestPatches when the initial fetch throws an abort-shaped TypeError but the signal is still active. Yields a microtask to let any pending abort() propagate, then re-checks before retrying. If the retry also fails, the error surfaces normally.
  • Manifest aborts are still gated strictly on signal?.aborted (no TypeError fallback in the outer catch), preserving consistency with discoverRoutes.

Tests

  • Unit tests for isAbortError covering signal state, AbortError instances, and the TypeError fallback (abort-test.ts).
  • Unit test in data-strategy-test.ts asserting aborted navigations produce an AbortError result rather than undefined.
  • Integration test in fetcher-test.ts exercising navigation during fetcher polling without surfacing errors.

Known limitations

  • The manifest fetch retry handles the most common race but does not yet cover all observed __manifest failures in production. We're still seeing occasional manifest errors in the field — this PR reduces them substantially but does not eliminate them. Additional investigation likely needed as follow-up.
  • No targeted unit tests yet for the manifest retry path or the SingleFetchNoResultError fallback; both currently rely on integration coverage from real-world usage. Happy to add focused tests if reviewers want them.

Impact

Eliminates the most common abort-related crashes (TypeError: Failed to fetch, Cannot read properties of undefined (reading 'type'), Did not find corresponding fetcher result, No result found for routeId) while leaving real navigation failures and real manifest failures visible.

Normalize fetch aborts and ignore abort results during data strategy.
Add integration coverage for navigation during fetcher polling.
@remix-cla-bot

remix-cla-bot Bot commented Jan 30, 2026

Copy link
Copy Markdown
Contributor

Hi @yoni-noma,

Welcome, and thank you for contributing to React Router!

Before we consider your pull request, we ask that you sign our Contributor License Agreement (CLA). We require this only once.

You may review the CLA and sign it by adding your name to contributors.yml.

Once the CLA is signed, the CLA Signed label will be added to the pull request.

If you have already signed the CLA and received this response in error, or if you have any questions, please contact us at hello@remix.run.

Thanks!

- The Remix team

Add changeset for abort handling fix and sign CLA.
@remix-cla-bot

remix-cla-bot Bot commented Jan 30, 2026

Copy link
Copy Markdown
Contributor

Thank you for signing the Contributor License Agreement. Let's get this merged! 🥳

Prefer signal reason and AbortError types; only fall back to message
matching for fetch TypeError cases.
Note TypeError message matching is a browser fallback only.
@yoni-noma

Copy link
Copy Markdown
Contributor Author

Added a short note in code: we prefer abort signal/AbortError checks and only fall back to TypeError message matching to handle browser abort race cases. The fallback is intentionally narrow (TypeError-only) to minimize false positives. Should improve behavior for Chrome/Safari/Firefox where aborted fetches sometimes surface as TypeError.

Extract internal isAbortError helper to avoid duplication.
yoni-noma and others added 5 commits February 3, 2026 10:47
Ensure abort races surface AbortError consistently and add a targeted
dataStrategy interruption test to guard the regression.

Co-authored-by: Cursor <cursoragent@cursor.com>
Avoid treating TypeError as abort in manifest patch fetches.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep debug logging out of upstream change set.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Writing `{ type: data, data: undefined }` for aborted route results
causes mergeLoaderData to overwrite existing valid loaderData with
undefined. This crashes hooks like useRouteLoaderData('root') that
expect data to be present (e.g. "No requestInfo found in root loader").

Instead, skip aborted routes entirely so mergeLoaderData preserves
the previous valid data. The outer catch path already does this
correctly by returning empty dataResults.

Co-authored-by: Cursor <cursoragent@cursor.com>
@yoni-noma

Copy link
Copy Markdown
Contributor Author

Fix: per-route abort suppression was wiping loaderData

We discovered a bug in the per-route abort suppression logic introduced in this PR. When an aborted route result was detected in the callDataStrategy per-route loop, it was being converted to { type: ResultType.data, data: undefined }. This caused processRouteLoaderData to write undefined into loaderData, and mergeLoaderData to overwrite existing valid data with undefined.

Impact: Hooks like useRouteLoaderData('root') returned undefined after an abort race condition, causing crashes like "No requestInfo found in root loader".

Root cause: The onReject normalization (which rejects with a proper AbortError instead of undefined) made the per-route isAbortError check reachable. Before normalization, result.result was undefined and didn't match any abort pattern, so the { data: undefined } write was never reached.

Fix (commit e21f7ed): Skip aborted routes entirely (continue without writing to dataResults) instead of writing { data: undefined }. This way mergeLoaderData preserves the existing valid data for that route. The outer catch path already does this correctly by returning empty dataResults.

This is safe because:

  • Routes absent from dataResults are skipped by processRouteLoaderData (line 6579: if (!(match.route.id in results)) return)
  • mergeLoaderData preserves existing data for routes not present in newLoaderData (line 6732-6737)
  • The abort means a new navigation is taking over, which will load fresh data
  • The signal.aborted check after callDataStrategyImpl (line 3060) already discards everything if the signal caught up in time

…etcher consumers

When callDataStrategy skips a route result via abort detection (isAbortError),
downstream fetcher consumers crash because they assume results always exist:
- handleFetcherLoader: result is undefined → isErrorResult(undefined) throws
- handleFetcherAction: actionResult is undefined → isRedirectResult crashes
- revalidation fetchers: result is undefined → invariant("Did not find
  corresponding fetcher result") throws

Add null guards in all three consumer paths to gracefully settle fetchers
with their previous data when results are missing due to abort detection.

Co-authored-by: Cursor <cursoragent@cursor.com>
@yoni-noma

yoni-noma commented Feb 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Fix: defensive null guards for skipped abort results in fetcher consumers

We identified two production errors caused by callDataStrategy's abort detection (isAbortError) skipping route results, while downstream fetcher consumers assume results always exist.

Sentry issues

  • TypeError: Cannot read properties of undefined (reading 'type') — in handleFetcherLoader → isErrorResult(undefined) crashes when results[match.route.id] is undefined
  • Error: Did not find corresponding fetcher result — in processLoaderData → invariant(result, ...) crashes when revalidation fetcher result is missing

Root cause

When a browser-level abort occurs (e.g., user navigates while fetchers are in-flight), the fetch throws a TypeError("Failed to fetch") with response code 0 while signal.aborted is still false. The isAbortError heuristic in callDataStrategy correctly identifies this as an abort and skips the result (via continue or returning empty dataResults). However, the consumers of these results don't handle the missing entry:

  1. handleFetcherLoader — reads results[match.route.id] → undefined → signal.aborted is false → falls through to isErrorResult(undefined) → crash
  2. handleFetcherAction — actionResult is undefined after fallback loop → isRedirectResult(undefined) / isErrorResult(undefined) → crash
  3. Revalidation fetchers — callLoadersAndMaybeResolveData returns { [key]: undefined } → processLoaderData checks controller.signal.aborted → false → invariant(result, "Did not find corresponding fetcher result") → crash

Fix

Add null guards in all three consumer paths. When a result is missing (skipped by abort detection), the fetcher gracefully settles to idle with its previous data preserved — analogous to how mergeLoaderData already preserves existing navigation data for skipped routes.

No changes to abort detection logic — isAbortError, fetchAndDecodeViaTurboStream normalization, and callDataStrategy skip logic remain unchanged.

… in fetcher discovery error paths

The isAbortError check in fetchAndApplyManifestPatches was catching
DOMException("AbortError") even when signal.aborted was still false.
This created an inconsistency with discoverRoutes (which checks signal.aborted),
causing manifest fetches to silently return with no routes patched while
discoverRoutes proceeded as if no abort occurred — resulting in 404 errors.

Fix:
1. Revert fetchAndApplyManifestPatches to the original signal?.aborted check
2. Add isAbortError guards in handleFetcherAction and handleFetcherLoader
   discovery error paths to silently suppress abort-related discovery errors

This ensures consistency between fetchAndApplyManifestPatches and discoverRoutes,
while still gracefully handling abort-related errors in the fetcher consumers.

Co-authored-by: Cursor <cursoragent@cursor.com>
@yoni-noma

Copy link
Copy Markdown
Contributor Author

Update: Manifest abort detection fix + fetcher discovery error guards

Problem discovered (WEB-APP-1G9)

The isAbortError check in fetchAndApplyManifestPatches was catching DOMException("AbortError") even when signal.aborted was still false (race condition). This caused an inconsistency with discoverRoutes (which checks signal.aborted), leading to:

  1. fetchAndApplyManifestPatches silently returns — no routes patched
  2. discoverRoutes sees signal.aborted === false, proceeds as if no abort occurred
  3. Route matching fails → 404 error ("No route matches URL")

This was observed in CI (Playwright smoke tests) where rapid navigation creates tight abort timing windows.

Changes in this update

fog-of-war.ts: Reverted fetchAndApplyManifestPatches back to the original signal?.aborted check. This ensures consistency with discoverRoutes's abort detection.

router.ts: Added isAbortError guards in the discoverResult.type === "error" blocks of handleFetcherAction and handleFetcherLoader. These silently suppress abort-related discovery errors (e.g., manifest fetch aborted) in the fetcher consumers, preventing them from reaching error boundaries.

Navigation callers (the two startNavigation paths) are not modified — their existing error handling is appropriate for navigation context.

Summary of all changes in this PR

  1. Abort detection (abort.ts): isAbortError helper for robust abort detection
  2. Data strategy abort suppression (router.ts): Wrap callDataStrategy results to suppress abort-related errors
  3. Null guards for fetcher consumers (router.ts): Defensive checks in handleFetcherLoader, handleFetcherAction, callLoadersAndMaybeResolveData
  4. Manifest abort consistency (fog-of-war.ts): Keep signal?.aborted for consistency with discoverRoutes
  5. Fetcher discovery error guards (router.ts): isAbortError guards in fetcher discovery error paths

When the browser terminates a /__manifest fetch independently of
AbortController (e.g., rapid navigation), the fetch throws a
TypeError("Failed to fetch") while signal.aborted is still false.

Add a targeted one-time retry: yield one microtask tick to let any
pending abort() propagate, then re-check the signal. If it flipped
to aborted, treat as canceled. Otherwise retry once — real failures
still surface if the retry also fails.

This handles both navigation and fetcher callers uniformly at the
source, without requiring isAbortError guards in navigation paths.

Made-with: Cursor
…esult

During navigation races, the turbo-stream response can omit data for
routes that were expected, causing unwrapSingleFetchResult to throw
SingleFetchNoResultError.

When existing loaderData is available for the route, reuse it instead
of crashing. This is consistent with how mergeLoaderData already
preserves existing data for routes absent from newLoaderData. First-time
loads without prior data still throw as before.

Made-with: Cursor
@github-actions

Copy link
Copy Markdown
Contributor

👋 We've moved away from Changesets to our own internal changes process. Please convert your changesets file to a change file in the proper package directory (i.e., packages/react-router/.changes/patch.fix-some-bug.md).

@Tom-DK

Tom-DK commented May 18, 2026

Copy link
Copy Markdown

Any timeline when this could be released?

@yoni-noma

Copy link
Copy Markdown
Contributor Author

Any timeline when this could be released?

i dont think anyone reviewed it, not sure why because its pretty major issue, we had multiple crashes a week, this solved 98% of it but i think it should be reviewed carefully

@scolestock

Copy link
Copy Markdown

We think we're running into this issue as well.

yoni-noma added 2 commits May 28, 2026 12:02
Per https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files,
move the release note from .changeset/ to packages/react-router/.changes/
following the new naming convention (patch.<short-description>.md).
- Restore package.json version to match dev (was an inadvertent bump)
- Remove stale .changeset/ files (project migrated to .changes/)
- Revert prettier-style `X && X.abort()` changes that were unrelated to the fix
- Add comment in fog-of-war.ts explaining the microtask yield before retry
- Drop redundant `e instanceof TypeError` guard (isAbortError handles it)
- Extract `unwrapSingleFetchResultWithFallback` helper to remove duplication
- Add unit tests for the `isAbortError` helper
@yoni-noma

Copy link
Copy Markdown
Contributor Author

Refreshed the PR for review readiness:

  • Converted the changeset file to the new .changes/ format per the bot's note above.
  • Rewrote the description to reflect what the code actually does today (the original had drifted significantly across 17 commits).
  • Removed unrelated prettier-style noise from the diff.
  • Added unit tests for the new isAbortError helper.
  • Acknowledged in "Known limitations" that the manifest fetch retry only handles the most common race — we're still seeing occasional __manifest failures and would welcome guidance on that path specifically.

@brophdawg11 — would really appreciate a review pass here when you have the bandwidth. This has been open since January and we're aware of at least 3 production users (us, @Tom-DK, @scolestock) hitting the underlying race.

The branch is behind dev and shows conflicts; happy to rebase, but the rebase needs care because #14838 and #14900 touched the same regions we patch. Would prefer directional feedback on the approach before re-deriving the patch on top of those changes.

@Alopwer

Alopwer commented Jun 12, 2026

Copy link
Copy Markdown

Any updates on this issue? We have the same issue.

@davidesigner

Copy link
Copy Markdown

Same here, do you plan to merge in a near future?

@yoni-noma

Copy link
Copy Markdown
Contributor Author

Production validation update + two related stock bugs root-caused

Since the last refresh we finished a full root-cause investigation of the crash family that remained after this PR's abort fixes, and I want to close the loop here because several people in this thread are hitting it (@Tom-DK, @scolestock, @Alopwer, @davidesigner).

TL;DR: the remaining crashes were not abort-shape issues — they are three separate stock fog-of-war bugs, all downstream of one design flaw: the "route not yet discovered" guards assume matchRoutes returns null, and any app with a root catch-all route (routes/$.tsx) never gets null. Present unchanged through 7.18.1. I've filed them separately with deterministic jest repros that run in a react-router source clone, plus validated source-level fixes:

Production numbers, for anyone evaluating whether this PR family is worth running as a patch: we vendor this PR's changes plus the three fixes above via pnpm patch on 7.13.0, tagged per-version in Sentry. Over the last 45 days: 6 No result found for routeId crashes on the older patch versions, zero since the full set deployed 5 days ago, across the same traffic. We also captured breadcrumbs of the loop-guard fix firing in the wild and recovering cleanly where stock would have crashed.

@brophdawg11 the offer from May stands: happy to rebase this PR onto dev (I know #14838/#14900 touched the same regions) — but directional feedback on the approach first would make that worthwhile. If it's easier to review piecemeal, the two issues above are self-contained starting points with runnable repros.

@scolestock

Copy link
Copy Markdown

Any new news on this one?

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.

7 participants