From 6a95f13dedf90506dd1bc5635c9d624af167db28 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Fri, 30 Jan 2026 17:31:50 +0200 Subject: [PATCH 01/13] fix(router): suppress abort errors in single fetch Normalize fetch aborts and ignore abort results during data strategy. Add integration coverage for navigation during fetcher polling. --- integration/fetcher-test.ts | 49 +++++++++++++++++++ .../react-router/lib/dom/ssr/single-fetch.tsx | 20 +++++++- packages/react-router/lib/router/router.ts | 33 +++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index 3a04da1ff8..35dc54430f 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -88,6 +88,43 @@ test.describe("useFetcher", () => { } `, + "app/routes/fetcher-abort.tsx": js` + import { Link, useFetcher } from "react-router"; + import { useEffect, useState } from "react"; + + export async function loader() { + await new Promise(resolve => setTimeout(resolve, 150)); + return { ok: true }; + } + + export default function FetcherAbort() { + let fetcher = useFetcher(); + let [polling, setPolling] = useState(false); + + useEffect(() => { + if (!polling) return; + let intervalId = setInterval(() => { + fetcher.load("/fetcher-abort"); + }, 50); + return () => clearInterval(intervalId); + }, [polling, fetcher]); + + return ( +
+ + Go to Target +

{fetcher.state}

+
+ ); + } + `, + + "app/routes/target.tsx": js` + export default function Target() { + return

Target

; + } + `, + "app/routes/parent.tsx": js` import { Outlet } from "react-router"; @@ -415,6 +452,18 @@ test.describe("useFetcher", () => { ]), ); }); + + test("navigation during fetcher polling does not error", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/fetcher-abort"); + await page.getByRole("button", { name: "Start Polling" }).click(); + await page.waitForTimeout(200); + + await page.getByRole("link", { name: "Go to Target" }).click(); + await expect(page).toHaveURL(/\/target/); + await expect(page.getByRole("heading", { name: "Target" })).toBeVisible(); + }); }); test.describe("fetcher aborts and adjacent forms", () => { diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index fdf78ff273..7a5b53ac36 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -663,7 +663,25 @@ async function fetchAndDecodeViaTurboStream( } } - let res = await fetch(url, await createRequestInit(request)); + let res: Response; + try { + res = await fetch(url, await createRequestInit(request)); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + const errorName = e instanceof Error ? e.name : 'unknown'; + + // Check if this was an abort - the signal might not be marked as aborted yet + // due to browser race conditions, so also check for known abort error patterns + const isAbortError = + request.signal.aborted || + errorName === 'AbortError' || + /failed to fetch|load failed|network request failed|the operation was aborted/i.test(errorMessage); + + if (isAbortError) { + throw new DOMException("Aborted", "AbortError"); + } + throw e; + } // If this error'd without hitting the running server, then bubble a normal // `ErrorResponse` and don't try to decode the body with `turbo-stream`. diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 34c74cc28d..667971bac5 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3017,6 +3017,23 @@ export function createRouter(init: RouterInit): Router { ): Promise> { let results: Record; let dataResults: Record = {}; + let isAbortError = (error: unknown) => { + if (request.signal.aborted) { + return true; + } + if (typeof DOMException !== "undefined" && error instanceof DOMException) { + return error.name === "AbortError"; + } + if (error instanceof Error) { + return ( + error.name === "AbortError" || + /failed to fetch|load failed|network request failed|the operation was aborted/i.test( + error.message, + ) + ); + } + return false; + }; try { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, @@ -3027,6 +3044,12 @@ export function createRouter(init: RouterInit): Router { false, ); } catch (e) { + // If the request was aborted, don't treat it as an error - just return + // empty results. This prevents abort errors from bubbling to error boundary. + // See: https://github.com/remix-run/react-router/issues/14203 + if (isAbortError(e)) { + return dataResults; + } // If the outer dataStrategy method throws, just return the error for all // matches - and it'll naturally bubble to the root matches @@ -3071,6 +3094,16 @@ export function createRouter(init: RouterInit): Router { } for (let [routeId, result] of Object.entries(results)) { + // If this is an abort-related error result, convert to empty data result + // This prevents abort errors from bubbling to error boundary + // See: https://github.com/remix-run/react-router/issues/14203 + if (result.type === ResultType.error && isAbortError(result.result)) { + dataResults[routeId] = { + type: ResultType.data, + data: undefined, + }; + continue; + } if (isRedirectDataStrategyResult(result)) { let response = result.result as Response; dataResults[routeId] = { From 3189d7a9a6f558f2b37a1377aab0a82ce81fa85d Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Fri, 30 Jan 2026 17:47:33 +0200 Subject: [PATCH 02/13] chore: add changeset and cla entry Add changeset for abort handling fix and sign CLA. --- .changeset/fetcher-abort-fix.md | 5 +++++ contributors.yml | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/fetcher-abort-fix.md diff --git a/.changeset/fetcher-abort-fix.md b/.changeset/fetcher-abort-fix.md new file mode 100644 index 0000000000..f1a7241774 --- /dev/null +++ b/.changeset/fetcher-abort-fix.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Prevent abort-related fetcher errors from surfacing as navigation failures. diff --git a/contributors.yml b/contributors.yml index 58131406b9..948ff32c65 100644 --- a/contributors.yml +++ b/contributors.yml @@ -464,6 +464,7 @@ - xcsnowcity - xdaxer - yionr +- yoni-noma - yracnet - ytori - yuhwan-park From 9991026347d75d9d43219c14a0832238345d77c9 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Fri, 30 Jan 2026 17:52:25 +0200 Subject: [PATCH 03/13] fix(router): narrow abort detection signals Prefer signal reason and AbortError types; only fall back to message matching for fetch TypeError cases. --- .../react-router/lib/dom/ssr/single-fetch.tsx | 17 ++++++++++--- packages/react-router/lib/router/router.ts | 24 ++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 7a5b53ac36..e23e1f7be6 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -668,14 +668,25 @@ async function fetchAndDecodeViaTurboStream( res = await fetch(url, await createRequestInit(request)); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); - const errorName = e instanceof Error ? e.name : 'unknown'; + const signalReason = request.signal.reason; + const hasAbortReason = + (signalReason instanceof DOMException && + signalReason.name === "AbortError") || + (signalReason instanceof Error && signalReason.name === "AbortError"); // Check if this was an abort - the signal might not be marked as aborted yet // due to browser race conditions, so also check for known abort error patterns const isAbortError = request.signal.aborted || - errorName === 'AbortError' || - /failed to fetch|load failed|network request failed|the operation was aborted/i.test(errorMessage); + hasAbortReason || + (typeof DOMException !== "undefined" && + e instanceof DOMException && + e.name === "AbortError") || + (e instanceof TypeError && + /failed to fetch|load failed|network request failed|the operation was aborted/i.test( + errorMessage, + )) || + (e instanceof Error && e.name === "AbortError"); if (isAbortError) { throw new DOMException("Aborted", "AbortError"); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 667971bac5..506d24ab02 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3021,17 +3021,29 @@ export function createRouter(init: RouterInit): Router { if (request.signal.aborted) { return true; } + + const signalReason = request.signal.reason; + if ( + signalReason instanceof DOMException && + signalReason.name === "AbortError" + ) { + return true; + } + if (signalReason instanceof Error && signalReason.name === "AbortError") { + return true; + } + if (typeof DOMException !== "undefined" && error instanceof DOMException) { return error.name === "AbortError"; } - if (error instanceof Error) { - return ( - error.name === "AbortError" || - /failed to fetch|load failed|network request failed|the operation was aborted/i.test( - error.message, - ) + if (error instanceof TypeError) { + return /failed to fetch|load failed|network request failed|the operation was aborted/i.test( + error.message, ); } + if (error instanceof Error) { + return error.name === "AbortError"; + } return false; }; try { From 83bf1984836ddad96edb38403147dc8b81f8a2f7 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Fri, 30 Jan 2026 17:52:34 +0200 Subject: [PATCH 04/13] chore: add Yonatani to contributors --- contributors.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/contributors.yml b/contributors.yml index 948ff32c65..986689a78a 100644 --- a/contributors.yml +++ b/contributors.yml @@ -464,6 +464,7 @@ - xcsnowcity - xdaxer - yionr +- Yonatani - yoni-noma - yracnet - ytori From d1721dd40b05016aecfa45c75ad81ad62e382382 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Fri, 30 Jan 2026 18:27:08 +0200 Subject: [PATCH 05/13] docs(router): clarify abort fallback rationale Note TypeError message matching is a browser fallback only. --- packages/react-router/lib/dom/ssr/single-fetch.tsx | 4 ++-- packages/react-router/lib/router/router.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index e23e1f7be6..268b765d76 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -674,8 +674,8 @@ async function fetchAndDecodeViaTurboStream( signalReason.name === "AbortError") || (signalReason instanceof Error && signalReason.name === "AbortError"); - // Check if this was an abort - the signal might not be marked as aborted yet - // due to browser race conditions, so also check for known abort error patterns + // Prefer signal/AbortError checks and only fall back to message matching + // for TypeError cases where some browsers report fetch aborts this way. const isAbortError = request.signal.aborted || hasAbortReason || diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 506d24ab02..6c9542cc3c 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3037,6 +3037,7 @@ export function createRouter(init: RouterInit): Router { return error.name === "AbortError"; } if (error instanceof TypeError) { + // Fallback for browsers that surface aborted fetches as TypeError return /failed to fetch|load failed|network request failed|the operation was aborted/i.test( error.message, ); From 0fc8dbad954b1008c6fa149b1d42d28dc5de1887 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Fri, 30 Jan 2026 18:55:31 +0200 Subject: [PATCH 06/13] refactor(router): share abort detection helper Extract internal isAbortError helper to avoid duplication. --- .../react-router/lib/dom/ssr/single-fetch.tsx | 24 +----------- packages/react-router/lib/router/abort.ts | 33 ++++++++++++++++ packages/react-router/lib/router/router.ts | 38 +++---------------- 3 files changed, 41 insertions(+), 54 deletions(-) create mode 100644 packages/react-router/lib/router/abort.ts diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 268b765d76..eeecc3ffd1 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -15,6 +15,7 @@ import { data, stripBasename, } from "../../router/utils"; +import { isAbortError } from "../../router/abort"; import { createRequestInit } from "./data"; import type { AssetsManifest, EntryContext } from "./entry"; import { escapeHtml } from "./markup"; @@ -667,28 +668,7 @@ async function fetchAndDecodeViaTurboStream( try { res = await fetch(url, await createRequestInit(request)); } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - const signalReason = request.signal.reason; - const hasAbortReason = - (signalReason instanceof DOMException && - signalReason.name === "AbortError") || - (signalReason instanceof Error && signalReason.name === "AbortError"); - - // Prefer signal/AbortError checks and only fall back to message matching - // for TypeError cases where some browsers report fetch aborts this way. - const isAbortError = - request.signal.aborted || - hasAbortReason || - (typeof DOMException !== "undefined" && - e instanceof DOMException && - e.name === "AbortError") || - (e instanceof TypeError && - /failed to fetch|load failed|network request failed|the operation was aborted/i.test( - errorMessage, - )) || - (e instanceof Error && e.name === "AbortError"); - - if (isAbortError) { + if (isAbortError(e, request.signal)) { throw new DOMException("Aborted", "AbortError"); } throw e; diff --git a/packages/react-router/lib/router/abort.ts b/packages/react-router/lib/router/abort.ts new file mode 100644 index 0000000000..fb8eaaeefa --- /dev/null +++ b/packages/react-router/lib/router/abort.ts @@ -0,0 +1,33 @@ +export function isAbortError(error: unknown, signal: AbortSignal) { + if (signal.aborted) { + return true; + } + + const hasDomException = typeof DOMException !== "undefined"; + const signalReason = signal.reason; + + if ( + hasDomException && + signalReason instanceof DOMException && + signalReason.name === "AbortError" + ) { + return true; + } + if (signalReason instanceof Error && signalReason.name === "AbortError") { + return true; + } + + if (hasDomException && error instanceof DOMException) { + return error.name === "AbortError"; + } + if (error instanceof TypeError) { + // Fallback for browsers that surface aborted fetches as TypeError + return /failed to fetch|load failed|network request failed|the operation was aborted/i.test( + error.message, + ); + } + if (error instanceof Error) { + return error.name === "AbortError"; + } + return false; +} diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 6c9542cc3c..8ad7223fba 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -9,6 +9,7 @@ import { parsePath, warning, } from "./history"; +import { isAbortError } from "./abort"; import type { unstable_ClientInstrumentation, unstable_InstrumentRouteFunction, @@ -3017,36 +3018,6 @@ export function createRouter(init: RouterInit): Router { ): Promise> { let results: Record; let dataResults: Record = {}; - let isAbortError = (error: unknown) => { - if (request.signal.aborted) { - return true; - } - - const signalReason = request.signal.reason; - if ( - signalReason instanceof DOMException && - signalReason.name === "AbortError" - ) { - return true; - } - if (signalReason instanceof Error && signalReason.name === "AbortError") { - return true; - } - - if (typeof DOMException !== "undefined" && error instanceof DOMException) { - return error.name === "AbortError"; - } - if (error instanceof TypeError) { - // Fallback for browsers that surface aborted fetches as TypeError - return /failed to fetch|load failed|network request failed|the operation was aborted/i.test( - error.message, - ); - } - if (error instanceof Error) { - return error.name === "AbortError"; - } - return false; - }; try { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, @@ -3060,7 +3031,7 @@ export function createRouter(init: RouterInit): Router { // If the request was aborted, don't treat it as an error - just return // empty results. This prevents abort errors from bubbling to error boundary. // See: https://github.com/remix-run/react-router/issues/14203 - if (isAbortError(e)) { + if (isAbortError(e, request.signal)) { return dataResults; } // If the outer dataStrategy method throws, just return the error for all @@ -3110,7 +3081,10 @@ export function createRouter(init: RouterInit): Router { // If this is an abort-related error result, convert to empty data result // This prevents abort errors from bubbling to error boundary // See: https://github.com/remix-run/react-router/issues/14203 - if (result.type === ResultType.error && isAbortError(result.result)) { + if ( + result.type === ResultType.error && + isAbortError(result.result, request.signal) + ) { dataResults[routeId] = { type: ResultType.data, data: undefined, From 3fc328b9d3949fe6366d0a35031e981af3495bd6 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Tue, 3 Feb 2026 10:47:40 +0200 Subject: [PATCH 07/13] fix(router): normalize abort errors and add test Ensure abort races surface AbortError consistently and add a targeted dataStrategy interruption test to guard the regression. Co-authored-by: Cursor --- .../__tests__/router/data-strategy-test.ts | 57 +++++++++++++ .../react-router/lib/dom/ssr/fog-of-war.ts | 5 +- .../react-router/lib/dom/ssr/single-fetch.tsx | 2 +- packages/react-router/lib/router/abort.ts | 14 +++- packages/react-router/lib/router/router.ts | 82 +++++++++++++++---- packages/react-router/package.json | 2 +- 6 files changed, 142 insertions(+), 20 deletions(-) diff --git a/packages/react-router/__tests__/router/data-strategy-test.ts b/packages/react-router/__tests__/router/data-strategy-test.ts index 033d66b4c0..32b493461e 100644 --- a/packages/react-router/__tests__/router/data-strategy-test.ts +++ b/packages/react-router/__tests__/router/data-strategy-test.ts @@ -792,6 +792,63 @@ describe("router dataStrategy", () => { }); }); + describe("aborts", () => { + it("returns AbortError results when a navigation is interrupted", async () => { + let resultsByPathname = new Map< + string, + Record + >(); + let dataStrategy = mockDataStrategy(({ matches, request }) => { + return Promise.all(matches.map((m) => m.resolve())).then((results) => { + let keyed = keyedResults(matches, results); + resultsByPathname.set(new URL(request.url).pathname, keyed); + return keyed; + }); + }); + let t = setup({ + routes: [ + { + path: "/", + id: "root", + loader: true, + children: [ + { + id: "foo", + path: "foo", + loader: true, + }, + { + id: "bar", + path: "bar", + loader: true, + }, + ], + }, + ], + dataStrategy, + hydrationData: { + loaderData: { + root: "ROOT", + }, + }, + }); + + let A = await t.navigate("/foo"); + let B = await t.navigate("/bar"); + await B.loaders.root.resolve("ROOT*"); + await B.loaders.bar.resolve("BAR"); + await tick(); + + expect(A.loaders.foo.signal.aborted).toBe(true); + let abortedResults = resultsByPathname.get("/foo"); + expect(abortedResults).toBeDefined(); + expect(abortedResults?.foo?.type).toBe("error"); + expect(abortedResults?.foo?.result).toEqual( + expect.objectContaining({ name: "AbortError" }), + ); + }); + }); + describe("actions", () => { it("should allow a custom implementation to passthrough to default behavior", async () => { let dataStrategy = mockDataStrategy(({ matches }) => diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index 7541bd5383..6000856acf 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -2,6 +2,7 @@ import * as React from "react"; import type { PatchRoutesOnNavigationFunction } from "../../context"; import type { Router as DataRouter } from "../../router/router"; import type { RouteManifest } from "../../router/utils"; +import { isAbortError } from "../../router/abort"; import { matchRoutes } from "../../router/utils"; import type { AssetsManifest } from "./entry"; import type { RouteModules } from "./routeModules"; @@ -303,7 +304,9 @@ export async function fetchAndApplyManifestPatches( } serverPatches = (await res.json()) as AssetsManifest["routes"]; } catch (e) { - if (signal?.aborted) return; + if (signal && isAbortError(e, signal, { allowTypeError: true })) { + return; + } throw e; } diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index eeecc3ffd1..fa02d81dd9 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -668,7 +668,7 @@ async function fetchAndDecodeViaTurboStream( try { res = await fetch(url, await createRequestInit(request)); } catch (e) { - if (isAbortError(e, request.signal)) { + if (isAbortError(e, request.signal, { allowTypeError: args.fetcherKey != null })) { throw new DOMException("Aborted", "AbortError"); } throw e; diff --git a/packages/react-router/lib/router/abort.ts b/packages/react-router/lib/router/abort.ts index fb8eaaeefa..11a4c0cc72 100644 --- a/packages/react-router/lib/router/abort.ts +++ b/packages/react-router/lib/router/abort.ts @@ -1,4 +1,13 @@ -export function isAbortError(error: unknown, signal: AbortSignal) { +type AbortErrorOptions = { + allowTypeError?: boolean; +}; + +export function isAbortError( + error: unknown, + signal: AbortSignal, + options: AbortErrorOptions = {}, +) { + const { allowTypeError = true } = options; if (signal.aborted) { return true; } @@ -21,6 +30,9 @@ export function isAbortError(error: unknown, signal: AbortSignal) { return error.name === "AbortError"; } if (error instanceof TypeError) { + if (!allowTypeError) { + return false; + } // Fallback for browsers that surface aborted fetches as TypeError return /failed to fetch|load failed|network request failed|the operation was aborted/i.test( error.message, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 8ad7223fba..5cdcd41694 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1228,7 +1228,12 @@ export function createRouter(init: RouterInit): Router { removePageHideEventListener(); } subscribers.clear(); - pendingNavigationController && pendingNavigationController.abort(); + if (pendingNavigationController) { + if (process.env.NODE_ENV !== "production") { + console.warn("[RR-DEBUG] abortNavigation", { reason: "router:dispose" }); + } + pendingNavigationController.abort(); + } state.fetchers.forEach((_, key) => deleteFetcher(key)); state.blockers.forEach((_, key) => deleteBlocker(key)); } @@ -1677,7 +1682,14 @@ export function createRouter(init: RouterInit): Router { // Abort any in-progress navigations and start a new one. Unset any ongoing // uninterrupted revalidations unless told otherwise, since we want this // new navigation to update history normally - pendingNavigationController && pendingNavigationController.abort(); + if (pendingNavigationController) { + if (process.env.NODE_ENV !== "production") { + console.warn("[RR-DEBUG] abortNavigation", { + reason: "navigation:restart", + }); + } + pendingNavigationController.abort(); + } pendingNavigationController = null; pendingAction = historyAction; isUninterruptedRevalidation = @@ -2210,7 +2222,7 @@ export function createRouter(init: RouterInit): Router { } revalidatingFetchers.forEach((rf) => { - abortFetcher(rf.key); + abortFetcher(rf.key, "revalidation:reset"); if (rf.controller) { // Fetchers use an independent AbortController so that aborting a fetcher // (via deleteFetcher) does not abort the triggering navigation that @@ -2221,7 +2233,9 @@ export function createRouter(init: RouterInit): Router { // Proxy navigation abort through to revalidation fetchers let abortPendingFetchRevalidations = () => - revalidatingFetchers.forEach((f) => abortFetcher(f.key)); + revalidatingFetchers.forEach((f) => + abortFetcher(f.key, "revalidation:navigation-abort"), + ); if (pendingNavigationController) { pendingNavigationController.signal.addEventListener( "abort", @@ -2342,7 +2356,7 @@ export function createRouter(init: RouterInit): Router { href: string | null, opts?: RouterFetchOptions, ) { - abortFetcher(key); + abortFetcher(key, "fetcher:start"); let flushSync = (opts && opts.flushSync) === true; @@ -2625,7 +2639,7 @@ export function createRouter(init: RouterInit): Router { existingFetcher ? existingFetcher.data : undefined, ); state.fetchers.set(staleKey, revalidatingFetcher); - abortFetcher(staleKey); + abortFetcher(staleKey, "revalidation:stale-fetcher"); if (rf.controller) { fetchControllers.set(staleKey, rf.controller); } @@ -2634,7 +2648,9 @@ export function createRouter(init: RouterInit): Router { updateState({ fetchers: new Map(state.fetchers) }); let abortPendingFetchRevalidations = () => - revalidatingFetchers.forEach((rf) => abortFetcher(rf.key)); + revalidatingFetchers.forEach((rf) => + abortFetcher(rf.key, "revalidation:action-abort"), + ); abortController.signal.addEventListener( "abort", @@ -2713,7 +2729,14 @@ export function createRouter(init: RouterInit): Router { loadId > pendingNavigationLoadId ) { invariant(pendingAction, "Expected pending action"); - pendingNavigationController && pendingNavigationController.abort(); + if (pendingNavigationController) { + if (process.env.NODE_ENV !== "production") { + console.warn("[RR-DEBUG] abortNavigation", { + reason: "navigation:replaced-by-fetcher", + }); + } + pendingNavigationController.abort(); + } completeNavigation(state.navigation.location, { matches, @@ -3031,7 +3054,7 @@ export function createRouter(init: RouterInit): Router { // If the request was aborted, don't treat it as an error - just return // empty results. This prevents abort errors from bubbling to error boundary. // See: https://github.com/remix-run/react-router/issues/14203 - if (isAbortError(e, request.signal)) { + if (isAbortError(e, request.signal, { allowTypeError: fetcherKey != null })) { return dataResults; } // If the outer dataStrategy method throws, just return the error for all @@ -3083,7 +3106,9 @@ export function createRouter(init: RouterInit): Router { // See: https://github.com/remix-run/react-router/issues/14203 if ( result.type === ResultType.error && - isAbortError(result.result, request.signal) + isAbortError(result.result, request.signal, { + allowTypeError: fetcherKey != null, + }) ) { dataResults[routeId] = { type: ResultType.data, @@ -3172,7 +3197,7 @@ export function createRouter(init: RouterInit): Router { if (fetchControllers.has(key)) { cancelledFetcherLoads.add(key); } - abortFetcher(key); + abortFetcher(key, "interruptActiveLoads"); }); } @@ -3218,7 +3243,7 @@ export function createRouter(init: RouterInit): Router { } function resetFetcher(key: string, opts?: { reason?: unknown }) { - abortFetcher(key, opts?.reason); + abortFetcher(key, opts?.reason ?? "fetcher:reset"); updateFetcherState(key, getDoneFetcher(null)); } @@ -3231,7 +3256,7 @@ export function createRouter(init: RouterInit): Router { fetchControllers.has(key) && !(fetcher && fetcher.state === "loading" && fetchReloadIds.has(key)) ) { - abortFetcher(key); + abortFetcher(key, "fetcher:delete"); } fetchLoadMatches.delete(key); fetchReloadIds.delete(key); @@ -3255,6 +3280,9 @@ export function createRouter(init: RouterInit): Router { function abortFetcher(key: string, reason?: unknown) { let controller = fetchControllers.get(key); if (controller) { + if (process.env.NODE_ENV !== "production") { + console.warn("[RR-DEBUG] abortFetcher", { key, reason }); + } controller.abort(reason); fetchControllers.delete(key); } @@ -3291,7 +3319,7 @@ export function createRouter(init: RouterInit): Router { let fetcher = state.fetchers.get(key); invariant(fetcher, `Expected fetcher: ${key}`); if (fetcher.state === "loading") { - abortFetcher(key); + abortFetcher(key, "stale-fetch-load"); fetchReloadIds.delete(key); yeetedKeys.push(key); } @@ -6183,11 +6211,33 @@ async function callLoaderOrAction({ handler: boolean | LoaderFunction | ActionFunction, ): Promise => { // Setup a promise we can race against so that abort signals short circuit - let reject: () => void; + let reject: (reason?: unknown) => void; // This will never resolve so safe to type it as Promise to // satisfy the function return value let abortPromise = new Promise((_, r) => (reject = r)); - onReject = () => reject(); + onReject = () => { + const reason = request.signal.reason; + if (process.env.NODE_ENV !== "production") { + const message = reason instanceof Error ? reason.message : String(reason); + const name = reason instanceof Error ? reason.name : "unknown"; + console.warn("[RR-DEBUG] abortPromise", { name, message }); + } + if ( + reason instanceof Error || + (typeof DOMException !== "undefined" && reason instanceof DOMException) + ) { + reject(reason); + return; + } + const abortMessage = typeof reason === "string" ? reason : "Aborted"; + if (typeof DOMException !== "undefined") { + reject(new DOMException(abortMessage, "AbortError")); + } else { + const abortError = new Error(abortMessage); + abortError.name = "AbortError"; + reject(abortError); + } + }; request.signal.addEventListener("abort", onReject); let actualHandler = (ctx?: unknown) => { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index edfe9a3599..b82ce73ec9 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "7.13.0", + "version": "7.13.1", "description": "Declarative routing for React", "keywords": [ "react", From 667616450fe4c9bfc937e64d5edd3b5b53164593 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Wed, 4 Feb 2026 16:45:16 +0200 Subject: [PATCH 08/13] fix(router): tighten manifest abort detection Avoid treating TypeError as abort in manifest patch fetches. Co-authored-by: Cursor --- packages/react-router/lib/dom/ssr/fog-of-war.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index 6000856acf..19ae8616f2 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -304,7 +304,7 @@ export async function fetchAndApplyManifestPatches( } serverPatches = (await res.json()) as AssetsManifest["routes"]; } catch (e) { - if (signal && isAbortError(e, signal, { allowTypeError: true })) { + if (signal && isAbortError(e, signal, { allowTypeError: false })) { return; } throw e; From 14aa443a5068e05ae52ba201facd527ce2f20a28 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Wed, 4 Feb 2026 17:30:00 +0200 Subject: [PATCH 09/13] chore(router): remove abort debug logs Keep debug logging out of upstream change set. Co-authored-by: Cursor --- packages/react-router/lib/router/router.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 5cdcd41694..b9ba34a25f 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1229,9 +1229,6 @@ export function createRouter(init: RouterInit): Router { } subscribers.clear(); if (pendingNavigationController) { - if (process.env.NODE_ENV !== "production") { - console.warn("[RR-DEBUG] abortNavigation", { reason: "router:dispose" }); - } pendingNavigationController.abort(); } state.fetchers.forEach((_, key) => deleteFetcher(key)); @@ -1683,11 +1680,6 @@ export function createRouter(init: RouterInit): Router { // uninterrupted revalidations unless told otherwise, since we want this // new navigation to update history normally if (pendingNavigationController) { - if (process.env.NODE_ENV !== "production") { - console.warn("[RR-DEBUG] abortNavigation", { - reason: "navigation:restart", - }); - } pendingNavigationController.abort(); } pendingNavigationController = null; @@ -2730,11 +2722,6 @@ export function createRouter(init: RouterInit): Router { ) { invariant(pendingAction, "Expected pending action"); if (pendingNavigationController) { - if (process.env.NODE_ENV !== "production") { - console.warn("[RR-DEBUG] abortNavigation", { - reason: "navigation:replaced-by-fetcher", - }); - } pendingNavigationController.abort(); } @@ -3280,9 +3267,6 @@ export function createRouter(init: RouterInit): Router { function abortFetcher(key: string, reason?: unknown) { let controller = fetchControllers.get(key); if (controller) { - if (process.env.NODE_ENV !== "production") { - console.warn("[RR-DEBUG] abortFetcher", { key, reason }); - } controller.abort(reason); fetchControllers.delete(key); } @@ -6217,11 +6201,6 @@ async function callLoaderOrAction({ let abortPromise = new Promise((_, r) => (reject = r)); onReject = () => { const reason = request.signal.reason; - if (process.env.NODE_ENV !== "production") { - const message = reason instanceof Error ? reason.message : String(reason); - const name = reason instanceof Error ? reason.name : "unknown"; - console.warn("[RR-DEBUG] abortPromise", { name, message }); - } if ( reason instanceof Error || (typeof DOMException !== "undefined" && reason instanceof DOMException) From 7599ad3aa4edef7a8f5a62d87ba847441d7584fb Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Thu, 5 Feb 2026 18:27:33 +0200 Subject: [PATCH 10/13] fix(router): treat single-fetch TypeErrors as aborts Co-authored-by: Cursor --- packages/react-router/lib/dom/ssr/single-fetch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index fa02d81dd9..3b51f7c24b 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -668,7 +668,7 @@ async function fetchAndDecodeViaTurboStream( try { res = await fetch(url, await createRequestInit(request)); } catch (e) { - if (isAbortError(e, request.signal, { allowTypeError: args.fetcherKey != null })) { + if (isAbortError(e, request.signal, { allowTypeError: true })) { throw new DOMException("Aborted", "AbortError"); } throw e; From e21f7ed794b6c8d5c182fcc063038136181e816e Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Fri, 6 Feb 2026 16:30:36 +0200 Subject: [PATCH 11/13] fix(router): skip aborted routes instead of writing undefined data 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 --- packages/react-router/lib/router/router.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index b9ba34a25f..87377d9a5c 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3088,8 +3088,10 @@ export function createRouter(init: RouterInit): Router { } for (let [routeId, result] of Object.entries(results)) { - // If this is an abort-related error result, convert to empty data result - // This prevents abort errors from bubbling to error boundary + // If this is an abort-related error result, skip it entirely so + // mergeLoaderData preserves the existing valid data for this route. + // Writing { data: undefined } would wipe loaderData and crash hooks + // like useRouteLoaderData('root') that expect data to be present. // See: https://github.com/remix-run/react-router/issues/14203 if ( result.type === ResultType.error && @@ -3097,10 +3099,6 @@ export function createRouter(init: RouterInit): Router { allowTypeError: fetcherKey != null, }) ) { - dataResults[routeId] = { - type: ResultType.data, - data: undefined, - }; continue; } if (isRedirectDataStrategyResult(result)) { From 15efd10497d5a701d662d0287e23163589944849 Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Wed, 11 Feb 2026 14:24:56 +0200 Subject: [PATCH 12/13] fix(router): add defensive null guards for skipped abort results in fetcher consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/react-router/lib/router/router.ts | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 87377d9a5c..0b22886734 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -2537,6 +2537,14 @@ export function createRouter(init: RouterInit): Router { return; } + // If the result was skipped by abort detection in callDataStrategy, + // gracefully settle the fetcher with its previous data instead of crashing + if (!actionResult) { + let currentFetcher = state.fetchers.get(key); + updateFetcherState(key, getDoneFetcher(currentFetcher?.data)); + return; + } + // We don't want errors bubbling up to the UI or redirects processed for // unmounted fetchers so we just revert them to idle if (fetchersQueuedForDeletion.has(key)) { @@ -2844,6 +2852,14 @@ export function createRouter(init: RouterInit): Router { return; } + // If the result was skipped by abort detection in callDataStrategy, + // gracefully settle the fetcher with its previous data instead of crashing + if (result == null) { + let currentFetcher = state.fetchers.get(key); + updateFetcherState(key, getDoneFetcher(currentFetcher?.data)); + return; + } + // If the loader threw a redirect Response, start a new REPLACE navigation if (isRedirectResult(result)) { if (pendingNavigationLoadId > originatingLoadId) { @@ -3146,6 +3162,18 @@ export function createRouter(init: RouterInit): Router { f.key, ); let result = results[f.match.route.id]; + // If the result was skipped by abort detection in callDataStrategy, + // synthesize a success result with previous data so downstream + // invariant checks don't crash + if (result == null) { + let existingFetcher = state.fetchers.get(f.key); + return { + [f.key]: { + type: ResultType.data, + data: existingFetcher?.data, + } as SuccessResult, + }; + } // Fetcher results are keyed by fetcher key from here on out, not routeId return { [f.key]: result }; } else { From 6bfdf4e3b8a04ed9b6e029aaabf515c050ba77ec Mon Sep 17 00:00:00 2001 From: yoni-noma Date: Wed, 11 Feb 2026 17:30:00 +0200 Subject: [PATCH 13/13] fix: revert manifest abort to signal?.aborted, add isAbortError guard in fetcher discovery error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/react-router/lib/dom/ssr/fog-of-war.ts | 3 +-- packages/react-router/lib/router/router.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index 19ae8616f2..9567572e34 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -2,7 +2,6 @@ import * as React from "react"; import type { PatchRoutesOnNavigationFunction } from "../../context"; import type { Router as DataRouter } from "../../router/router"; import type { RouteManifest } from "../../router/utils"; -import { isAbortError } from "../../router/abort"; import { matchRoutes } from "../../router/utils"; import type { AssetsManifest } from "./entry"; import type { RouteModules } from "./routeModules"; @@ -304,7 +303,7 @@ export async function fetchAndApplyManifestPatches( } serverPatches = (await res.json()) as AssetsManifest["routes"]; } catch (e) { - if (signal && isAbortError(e, signal, { allowTypeError: false })) { + if (signal?.aborted) { return; } throw e; diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 0b22886734..28ac2ede49 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -2469,6 +2469,13 @@ export function createRouter(init: RouterInit): Router { if (discoverResult.type === "aborted") { return; } else if (discoverResult.type === "error") { + if ( + isAbortError(discoverResult.error, fetchRequest.signal, { + allowTypeError: false, + }) + ) { + return; + } setFetcherError(key, routeId, discoverResult.error, { flushSync }); return; } else if (!discoverResult.matches) { @@ -2797,6 +2804,13 @@ export function createRouter(init: RouterInit): Router { if (discoverResult.type === "aborted") { return; } else if (discoverResult.type === "error") { + if ( + isAbortError(discoverResult.error, fetchRequest.signal, { + allowTypeError: false, + }) + ) { + return; + } setFetcherError(key, routeId, discoverResult.error, { flushSync }); return; } else if (!discoverResult.matches) {