TanStack / TanStack/router

Every persisted route match re-renders on every navigation

Open
#7,989 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

Every persisted route match re-renders on every navigation

Summary

In @tanstack/react-router, navigating from one route to a sibling re-renders
every mounted Match and MatchInner — including the matches that stayed,
whose rendered output is byte-for-byte identical before and after. Each of those
re-renders walks the whole MatchViewSuspenseCatchBoundary
CatchNotFoundMatchInner chain.

On a deep layout tree (app shell → section → list → detail) that is 3–4 wasted
component subtree renders per navigation, on the hot path of every link click.
Measured in a production app with a deep layout tree; reproduced here in a
minimal test.

There are two independent causes. Both are present on main; one of them is
present on the released 1.170.x line as well.

Cause 1 — loadedAt is observed by every match (released 1.170.x line)

Match subscribes to router.stores.loadedAt for one reason: to feed
CatchBoundary's getResetKey, so a route error clears on the next navigation.

const resetKey = useStore(router.stores.loadedAt, (loadedAt) => loadedAt)
...
<MatchView router={router} matchId={matchId} resetKey={resetKey} matchState={matchState} />

router-core writes loadedAt on every navigation, so this makes every
mounted match
re-render on every navigation — even routes that have no
errorComponent at all and can therefore never use the reset key.

On main this specific subscription is gone (the reset key is the match
identity now), so cause 1 does not apply there.

Cause 2 — MatchInner / Match select the whole match by identity (both lines)

matchRoutesInternal / buildMatches re-mints every staying match on every
navigation:

match = {
  ...existingMatch,
  cause,
  search: ...,
  _strictSearch: strictMatchSearch,   // fresh object
  searchError,
}

and the load pass then bumps fetchCount and swaps abortController (1.170.x)
or flips cause 'enter' → 'stay' (main). So the match store always
republishes with a new object identity, and the subscription

const match = useStore(matchStore, (value) => value)   // no equality fn

always reports a change — even though none of the changed fields is rendered.

The counting probe reports exactly which keys changed for a staying match on a
sibling navigation:

"changedKeys": [
  "_strictSearch(identity-only)",
  "context(identity-only)",
  "abortController(identity-only)",
  "fetchCount(value)"          // 1.170.17
]
"changedKeys": [
  "_strictSearch(identity-only)",
  "context(identity-only)",
  "abortController(identity-only)",
  "cause(value)"               // main
]

identity-only means the value is structurally equal to the previous one and
only the object identity changed. Nothing in that list reaches the DOM through
Match/MatchInner.

On main the same identity subscription lives in Match itself
(useStore(matchStore, (value) => value)), and it feeds MatchView and
MatchInner the whole match object as a prop, so React.memo on MatchInner
can never bail out.

Minimal reproduction

A four-level tree — root (which re-renders per navigation, as a real app shell
does) → /section (with validateSearch + loader) → /section/list (loader,
inline <Link search={{...}}> object literals) → leaf a/b. A basepath is
configured so path rewriting is exercised. React.memo is patched to count
renders of each *Impl component, and every match store is subscribed to for
publish counting.

The probe added in the accompanying PR
(packages/react-router/tests/match-rerender-probe.test.tsx) is exactly that,
and it prints PROBE_SIBLING_NAV / PROBE_SEARCH_NAV / PROBE_TWO_NAVS
reports.

Measured counts (before the fix)

Four matches are mounted (__root__, /section, /section/list, leaf). Three
of them stay across a sibling navigation.

Scenario branch MatchImpl renders MatchInnerImpl renders Should be
sibling /section/list/a → /b 1.170.17 4 4 1, 1
sibling /section/list/a → /b main 4 4 1, 1
search-only ?tab=all → ?tab=mine 1.170.17 4 4 0, 0
search-only ?tab=all → ?tab=mine main 4 4 0, 0
two navigations back and forth 1.170.17 8 8 2, 2
two navigations back and forth main 8 8 2, 2

Supporting counts on the sibling navigation (identical before and after any
render-side fix, since router-core is untouched):

  • staying match store publishes: 3
  • of those, publishes whose whole match is structurally equal to the previous
    one: 0 (fetchCount/cause really did change — the object is not deeply
    equal, it is just that nothing rendered depends on it)
  • router.stores.loadedAt fires: 1 per navigation (1.170.x)

The search-only case is the clearest: no match enters or leaves, search
reaches route components through useSearch, and yet all four Match and
all four MatchInner re-render.

Why it is not just React.memo's job

Match and MatchInner are already React.memo'd. The re-render is not driven
by a parent prop change, it is driven by their own store subscriptions
reporting a change. memo cannot help; the subscription has to select less.

Note for anyone measuring this

deepEqual in this repo defaults to ignoreUndefined: true, so it will call
two matches equal when one has an extra undefined-valued key. The probe
deliberately uses its own strict structural comparison for reporting, so
"identity-only" means identity-only and nothing else.

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 with packages/react-router/tests/match-rerender-probe.test.tsx and the Match/MatchInner store subscriptions described in the issue; compare main with the released 1.170.x behavior. Run the probe for sibling, search-only, and two-navigation cases. Done means staying matches no longer render unnecessarily and the reported counts match the stated expected values without changing router-core.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
frontend, performance, testing-qa
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.