Repository navigation
Router stays pending after a document redirect that does not unload the page #8608
Description
Which project does this relate to?
Router
Describe the bug
When a route's beforeLoad or loader throws a document redirect (redirect({ ..., reloadDocument: true }), or a redirect whose target is an external href), the client router follows it by starting a full document navigation (window.location.replace(...) / window.location.href = ...). The pending load transaction then ends without committing and without returning the router to idle.
That is fine when the browser actually unloads the page. But if the document navigation does not happen, the router is left stuck:
router.state.status === 'pending'androuter.state.isLoading === trueforever,router.state.locationis the location of the route that threw the redirect, whileresolvedLocationand the rendered matches are still the previous page,- any pending UI driven by
status/isLoading(global spinners,useRouterState({ select: s => s.isLoading }), etc.) never clears until some other navigation happens.
Ways to reach this in a real browser:
- the user cancels a
beforeunloadprompt (an app-levelbeforeunloadlistener, or the case described inbeforeunloaddialog triggered by redirect to external site even after original blocker proceed occurs #4094 where the prompt shows up on an external redirect); - the target does not replace the document: it triggers a download (
Content-Disposition: attachment) or opens an external protocol handler (mailto:,tel:, app deep links, ...); - the navigation is blocked or fails (e.g. a redirect
hrefwith a protocol rejected byprotocolAllowlist:documentNavigationjustconsole.warns and returns; or the browser/extension refuses the navigation); - in tests (jsdom), "Not implemented: navigation" leaves the router in the same state.
Complete minimal reproducer
No hosted reproducer yet; minimal sketch using only public APIs (code-based routes, React):
import {
createRootRoute, createRoute, createRouter, Link, Outlet,
redirect, RouterProvider, useRouterState,
} from '@tanstack/react-router'
function Status() {
const status = useRouterState({ select: (s) => s.status })
const href = useRouterState({ select: (s) => s.location.href })
const resolved = useRouterState({ select: (s) => s.resolvedLocation?.href })
return <pre>{JSON.stringify({ status, href, resolved })}</pre>
}
const rootRoute = createRootRoute({
component: () => (
<>
<Status />
<Link to="/out">leave</Link>
<Outlet />
</>
),
})
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => <h1>Home</h1> })
const outRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/out',
loader: () => {
// or: throw redirect({ to: '/somewhere', reloadDocument: true })
throw redirect({ href: 'https://example.com/', reloadDocument: true })
},
})
// Simulates "the user cancelled the leave-page prompt".
window.addEventListener('beforeunload', (e) => { e.preventDefault(); e.returnValue = '' })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute, outRoute]) })
// render <RouterProvider router={router} />Steps to Reproduce the Bug
- Open
/, interact with the page once (browsers require a user gesture before showingbeforeunloadprompts). - Click "leave". The loader throws the document redirect, the browser shows the leave-page prompt.
- Choose "Stay on page".
<Status />shows{"status":"pending","href":"/out","resolved":"/"}and stays like that;isLoadingstaystrue, the/UI keeps rendering. Nothing settles until another navigation is started.
Same result with mailto: / download targets (the page is never unloaded), with a redirect to a protocol not in protocolAllowlist, and in jsdom tests.
Code path (on main, 2741b159af, router-core 1.171.34)
packages/router-core/src/load-client.tsmaterializeRedirect(~L1062-1110): forhref/Locationredirects withreloadDocumentit returns the raw outcome; for external locations it returns[REDIRECTED, resolved, publicLocation].runClientTransaction(~L1798-1840): on a control outcome it callsfinishPending(clears only the pending-UI timing session) and thenawait followRedirect(router, tx, result)and returns. No commit, nostores.status.set('idle'), no restore ofstores.location.followRedirect(~L1701-1740): withreloadDocumentit callsrouter.navigate({ href, reloadDocument: true, replace: true, ignoreBlocker: true }); for an external location it callsrouter.commitLocation(...).loadClientRoute(~L1919-2060) setsstatus: 'pending'andlocationat the start of the transaction;statusis only set back to'idle'after a successful commit insiderunClientTransaction(~L1901-1904) and after hydration (~L2546).
packages/router-core/src/router.tsnavigate(~L2380-2420) andcommitLocation(~L2226-2242,nextLocation.external) both hand off todocumentNavigation.documentNavigation(~L2744-2788) returns without navigating when the protocol is blocked, or when a blocker returnstrue(not reachable from redirects, sincefollowRedirectpassesignoreBlocker: true), and otherwise assignswindow.locationand returns. In every case the caller has no way to tell whether the page is actually going away, and nobody settles the transaction afterwards.
Since this is in router-core's client load path, it affects all adapters (React, Solid, Vue) and Start on the client.
Secondary, observed in tests: a similar stuck-pending state happens when a view-transition callback throws during commit, e.g. viewTransition: { types: () => { throw ... } } or a throwing document.startViewTransition. runClientTransaction awaits router.startViewTransition(async () => { ... commit ... }) (~L1860), and router.startViewTransition (router.ts ~L2434) calls types(...) / document.startViewTransition before the commit callback runs, so a throw there skips both the commit and the status: 'idle' update.
Expected behavior
Once the document navigation did not happen (or was refused), the router should settle: return to status: 'idle' / isLoading: false, with location consistent with what is actually presented (i.e. back to the still-rendered resolvedLocation, or otherwise consistent with the matches on screen), so that pending UI clears and later navigations behave normally. The same applies when a view-transition callback throws: the navigation should either commit without the transition or settle as failed, not stay pending.
Screenshots or Videos
No response
Platform
- Router / Start Version:
main@2741b159af(@tanstack/router-core1.171.34); all adapters - OS: any
- Browser: any (also reproducible in jsdom)
- Bundler: any
Additional context
Related: #4094 (the beforeunload prompt shown on an external redirect from beforeLoad: cancelling that prompt is one way to end up in this state).
This report was investigated and written by an AI assistant (Claude Code) at the maintainer's request.
Activity