#15406·react-router

Fetcher submission to an undiscovered route hangs in `submitting` forever (no error, no timeout) when the /__manifest request stalls

Author: wra-solCreated Aug 17, 2026Updated Aug 20, 2026
Labelspkg:react-router
### I'm using React Router as a... Framework ### Reproduction Minimal framework-mode app (all files below): a resource route (`action` only, never linked, so it is not in the initial client manifest) and a page that `fetcher.submit()`s to it. An express middleware in front of the request handler simulates the `/__manifest` route-discovery request stalling (receiving no response and no error). Steps: 1. `npm install && npm run build` 2. `STALL_MANIFEST=1 node server.mjs`, open http://localhost:3999 3. Click **Submit to /api/thing** Observed (verified on 8.3.0): `fetcher.state` stays `"submitting"` **forever**. No error state, no rejection, nothing in the console, no timeout — the page just shows a pending submission indefinitely. Clicking again fires a fresh `/__manifest` fetch and a second fetcher pins the same way. Control: with `STALL_MANIFEST=0` the same click settles to `idle` with the action data immediately. Repro files (6 small files) **package.json** ```json { "name": "rr-fetcher-discovery-hang-repro", "private": true, "type": "module", "scripts": { "build": "react-router build", "start": "node server.mjs" }, "dependencies": { "@react-router/express": "8.3.0", "@react-router/node": "8.3.0", "express": "^5.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router": "8.3.0" }, "devDependencies": { "@react-router/dev": "8.3.0", "vite": "^7.0.0" } } ``` **react-router.config.ts** ```ts import type { Config } from "@react-router/dev/config"; export default { ssr: true } satisfies Config; ``` **vite.config.ts** ```ts import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [reactRouter()] }); ``` **app/routes.ts** ```ts import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("api/thing", "routes/api.thing.tsx"), ] satisfies RouteConfig; ``` **app/root.tsx** ```tsx import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; export default function Root() { return ( ); } ``` **app/routes/api.thing.tsx** ```tsx export async function action() { return Response.json({ ok: true }); } ``` **app/routes/home.tsx** ```tsx import { useFetcher } from "react-router"; export default function Home() { const fetcher = useFetcher<{ ok: boolean }>(); return ( fetcher.submit({}, { action: "/api/thing", method: "POST" })}> Submit to /api/thing

fetcher.state: {fetcher.state}

fetcher.data: {JSON.stringify(fetcher.data ?? null)}

); } ``` **server.mjs** ```js import { createRequestHandler } from "@react-router/express"; import express from "express"; const STALL = process.env.STALL_MANIFEST === "1"; const app = express(); app.use((req, _res, next) => { if (STALL && req.url.startsWith("/__manifest")) return; // never respond next(); }); app.use(express.static("build/client")); app.all("*splat", createRequestHandler({ build: await import("./build/server/index.js") })); app.listen(3999, () => console.log("repro on http://localhost:3999")); ``` ### System Info ``` react-router: 8.3.0 (also present on current `main` — fog-of-war.ts has no timeout path) @react-router/dev: 8.3.0 node: 25.x, macOS; browser: Chromium ``` ### Expected Behavior Some way for the app (or the user) to learn the submission is not going to happen — e.g. the discovery fetch aborts after a timeout and the fetcher settles into an error state that `useFetcher().data`/an ErrorBoundary can observe, the same way a failed action POST surfaces. ### Actual Behavior When `fetcher.submit()` targets a route not yet in the client manifest, `checkFogOfWar`/`fetchAndApplyManifestPatches` awaits `fetch('/__manifest?...')` with only the navigation-interruption `AbortSignal` — there is no timeout. If that request stalls (never resolves, never errors), the fetcher is pinned in `"submitting"` permanently and silently: no error state, no console output, no way for app code to detect or recover. Each retry click spawns another fetcher that re-runs discovery and pins the same way. Browsers apply no default fetch timeout, so nothing ever rescues it. How we hit this in a real app, for context: the page held several long-lived SSE (`EventSource`) connections to the same origin. Over HTTP/1.1 (localhost dev), those consumed all six per-origin connections, so the browser queued the `/__manifest` request indefinitely — same URL via `curl` answered in 11 ms. The result was "clicking the button does nothing, forever, with an empty console", which took a full debugging session to attribute because the failure has no observable signal anywhere. The express stall in the repro is just a deterministic stand-in for that queuing. We fixed our trigger (share one SSE connection), but the failure mode seems worth hardening: a stalled discovery request is indistinguishable from the app being healthy.