Every persisted route match re-renders on every navigation
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 MatchView → Suspense → CatchBoundary →
CatchNotFound → MatchInner 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/causereally did change — the object is not deeply
equal, it is just that nothing rendered depends on it) router.stores.loadedAtfires: 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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