TanStack / TanStack/router

lazyRouteComponent reload guard key collides on Safari, capping stale-deploy recovery at one per tab

Open Beginner friendly
#8,331 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
15.1k
Forks
1.9k
Avg merge
1d 20h
Merged PRs (30d)
143

Description

Which project does this relate to?

Router

Describe the bug

lazyRouteComponent's stale-deploy recovery guards its one-time reload with a sessionStorage key derived solely from the error message:

https://github.com/TanStack/router/blob/main/packages/react-router/src/lazyRouteComponent.tsx

const storageKey = `tanstack_router_reload:${error.message}`
if (!sessionStorage.getItem(storageKey)) {
  sessionStorage.setItem(storageKey, '1')
  window.location.reload()
  throw new Promise(() => {})
}

That works when the message identifies the chunk, which it does on Chrome and Firefox — both append the module URL. It does not on Safari, whose message is the bare string Importing a module script failed. with no URL. isModuleNotFoundError's own comments record this difference:

// chrome: "Failed to fetch dynamically imported module: http://localhost:5173/src/routes/posts.index.tsx?tsr-split"
// firefox: "error loading dynamically imported module: http://localhost:5173/src/routes/posts.index.tsx?tsr-split"
// safari: "Importing a module script failed."

So on Safari the key collapses to a single value — tanstack_router_reload:Importing a module script failed. — shared by every lazy route in the application, for the lifetime of the tab.

The consequence is that a Safari tab gets one recovery, ever, no matter how many separate chunks fail or how many deploys it lives through. The first stale chunk reloads and recovers; every subsequent one finds the key already set, skips the reload, and rethrows — leaving the user on the error component with no automatic recovery, which is the exact situation the guard exists to prevent.

Chrome and Firefox are unaffected: each chunk URL yields its own key, so the guard does what it is meant to do (suppress a reload loop for one chunk that stays missing, while still recovering other chunks).

Note this is distinct from #7633. That issue is about isModuleNotFoundError failing to classify certain failures; this is about the guard key being non-unique once a failure has been correctly classified. Widening the predicate does not address it, and #7653 (now closed) would not have.

Complete minimal reproducer

No hosted reproducer — this is reasoned from the source above rather than from a running app, because reproducing it needs two successive deploys against one long-lived Safari tab. The conditions are exact and the code path is short, so I hope the inspection is enough to act on; happy to build a reproducer if that is the blocker.

The observable prerequisite (Safari's message carrying no URL) is already asserted by the router's own source comments, quoted above.

Steps to Reproduce the Bug
  1. Build and deploy an app with at least two code-split routes, /a and /b, whose chunks are content-hashed.
  2. Open the app in Safari and stay on the home route without refreshing.
  3. Deploy a change that rehashes the chunks for both /a and /b. The old chunks are no longer served.
  4. Navigate to /a. The import fails, isModuleNotFoundError matches, the guard key is set, and the tab reloads — recovered, as designed.
  5. With that tab still open, deploy a second change that rehashes the chunks again.
  6. Navigate to /b (or back to /a).
Expected behavior

Step 6 performs one reload and recovers, the same as step 4 did — the guard should suppress a repeated reload for the same failing chunk, not for every chunk in the app.

Actual behavior

Step 6 performs no reload. sessionStorage already holds tanstack_router_reload:Importing a module script failed. from step 4, so the guard declines and the error is rethrown to the error component. The user is stuck until they refresh by hand.

Platform
  • Browser: Safari (any version; the behavior follows from Safari's error message wording)
  • Affected packages: @tanstack/react-router (guard), @tanstack/router-core (message classification)
  • Versions: reproduces on current latest@tanstack/react-router@1.170.34, @tanstack/router-core@1.171.29
Additional context

A fix would be to key the guard on something that identifies the chunk rather than on the message alone — the route id available at the lazyRouteComponent call site is the obvious candidate, since it is stable across deploys and unique per split route:

const storageKey = `tanstack_router_reload:${routeId}:${error.message}`

That preserves the existing loop protection (the same route failing twice in one tab still only reloads once) while letting a different route recover on its own.

Found while tracking down why a stale tab was not recovering after a deploy on a TanStack Start app. The server-side half of that turned out to be ours to fix, but this guard caps how well the built-in recovery can work on Safari regardless.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/react-router/src/lazyRouteComponent.tsx, where the sessionStorage guard is assembled, and trace the lazyRouteComponent call site to confirm the available route id. Preserve one-time reload protection for the same route while allowing separate Safari lazy-route failures to recover independently; verify the behavior across two lazy routes and successive stale-deploy failures.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
frontend, web-dev
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.