Skip to content

Comments

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

Open
yoni-noma wants to merge 13 commits intoremix-run:devfrom
yoni-noma:fix/fetcher-abort-errors
Open

fix(router): suppress abort errors in single fetch#14761
yoni-noma wants to merge 13 commits intoremix-run:devfrom
yoni-noma:fix/fetcher-abort-errors

Conversation

@yoni-noma
Copy link

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

Description

Fixes navigation/fetcher abort crashes caused by inconsistent abort error shapes (AbortError/TypeError/undefined) in single-fetch + data strategy. Normalizes aborts and ensures abortPromise rejects with AbortError so aborted handlers do not bubble to error boundaries. TypeError-message fallback only applies to fetcher aborts.

Related: #14203

Changes

  • Normalize abort detection via isAbortError with a TypeError fallback only for fetchers
  • Convert abort results to empty data in callDataStrategy and ignore manifest patch aborts
  • Reject abortPromise with AbortError (plus dev-only debug logs)
  • Add a targeted dataStrategy interruption test to assert AbortError results

Impact

Prevents abort-related TypeError/undefined errors from reaching the error boundary while keeping real navigation failures intact.

Test plan

  • pnpm test packages/react-router/tests/router/data-strategy-test.ts

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

changeset-bot bot commented Jan 30, 2026

🦋 Changeset detected

Latest commit: 6bfdf4e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
react-router Patch
@react-router/architect Patch
@react-router/cloudflare Patch
@react-router/dev Patch
react-router-dom Patch
@react-router/express Patch
@react-router/node Patch
@react-router/serve Patch
@react-router/fs-routes Patch
@react-router/remix-routes-option-adapter Patch
create-react-router Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@remix-cla-bot
Copy link
Contributor

remix-cla-bot bot commented Jan 30, 2026

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
Copy link
Contributor

remix-cla-bot bot commented Jan 30, 2026

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
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
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
Copy link
Author

yoni-noma commented Feb 11, 2026

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 handleFetcherLoaderisErrorResult(undefined) crashes when results[match.route.id] is undefined
  • Error: Did not find corresponding fetcher result — in processLoaderDatainvariant(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]undefinedsignal.aborted is false → falls through to isErrorResult(undefined) → crash
  2. handleFetcherActionactionResult is undefined after fallback loop → isRedirectResult(undefined) / isErrorResult(undefined) → crash
  3. Revalidation fetchers — callLoadersAndMaybeResolveData returns { [key]: undefined }processLoaderData checks controller.signal.abortedfalseinvariant(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
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

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.

2 participants