Skip to content

perf(react-router): stop persisted matches re-rendering on every navigation - #7990

Draft
matclayton wants to merge 4 commits into
TanStack:mainfrom
matclayton:match-rerender-main
Draft

perf(react-router): stop persisted matches re-rendering on every navigation#7990
matclayton wants to merge 4 commits into
TanStack:mainfrom
matclayton:match-rerender-main

Conversation

@matclayton

@matclayton matclayton commented Aug 6, 2026

Copy link
Copy Markdown

perf(react-router): stop persisted matches re-rendering on every navigation

Fixes the churn described in the accompanying issue (#7989): every mounted
Match and MatchInner re-rendered on every navigation, including matches that
stayed and rendered identical output.

Closes #7989.

Branch: match-rerender-main (3 commits, off abf9b81b1f).

What changed

packages/react-router/src/Match.tsx only.

1. Match selects the fields its subtree renders, instead of the whole match
by identity.

-const match = useStore(matchStore, (value) => value)
-return <MatchView router={router} match={match!} />
+const selection = useStore(
+  matchStore,
+  (match) => selectMatchFields(router, routeId, match),
+  matchSelectionEqual,
+)
+return <MatchView router={router} routeId={routeId} selection={selection} />

The selection is a labelled tuple of [matchId, ssr, status, error, remountKey, lazy]. loaderDeps, _strictParams and _strictSearch only reach the output
through remountDeps, so the remount key is computed inside the selector and
compared as a string — which also retires the MatchInner key useMemo,
which never held anyway (a fresh _strictSearch object invalidated it every
navigation). MatchInner now receives primitives, so its React.memo can
actually bail out.

2. The per-navigation identity that resets CatchBoundary is observed in a
wrapper that only mounts for routes that have an errorComponent.

function ResettableCatchBoundary({ routeId, ...props }) {
  const router = useRouter()
  const resetKey = (isServer ?? router.isServer)
    ? router.stores.byRoute.get(routeId)!.get()
    : useStore(router.stores.getMatchStore(routeId), (match) => match)

  return <CatchBoundary getResetKey={() => resetKey} {...props} />
}

getResetKey={() => match} was the whole reason Match needed the match
identity. Moving that read down means only routes that can actually show an
error pay for it, and because props.children is the element MatchView
already created, the subtree below the wrapper bails out even when the wrapper
itself re-renders.

3. route._lazy is part of the selection. A lazy route's options are
Object.assigned onto the route object in place, and a re-offered pending match
is the only signal that the components this subtree renders have just been
replaced. Without it,
tests/issue-4467-lazy-route-pending.test.tsx › a lazy pending component is offered while the eager loader is still pending fails (it keeps showing
Loading default instead of Loading lazy page). _lazy is @internal, so it
is stripped from the published declarations and is read through a narrow local
structural type.

Red / green counts

Counted by patching React.memo to wrap every *Impl component with a counter
(packages/react-router/tests/match-rerender-probe.test.tsx, added in the first
commit as reporting-only and asserted in the third). Four matches mounted, three
of them staying across a sibling navigation.

main (this PR)

Scenario MatchImpl before after MatchInnerImpl before after
sibling /section/list/a → /b 4 1 4 1
search-only ?tab=all → ?tab=mine 4 0 4 0
two navigations back and forth 8 2 8 2

OutletImpl is unchanged (1 / 0 / 2) — the fix does not suppress renders that
are genuinely required.

Store-side counts, unchanged by the fix because router-core is untouched:
3 staying-match publishes per sibling navigation, 0 of which are structurally
equal to the previous value.

Released 1.170.17 line, for reference

The same shape of fix applies there, and it splits into two independently
measurable hunks. Match on 1.170.x also subscribes to router.stores.loadedAt
purely for getResetKey.

Scenario baseline + loadedAt hunk only + both hunks
sibling — MatchImpl / MatchInnerImpl 4 / 4 1 / 4 1 / 1
search-only — MatchImpl / MatchInnerImpl 4 / 4 0 / 4 0 / 0
two navs — MatchImpl / MatchInnerImpl 8 / 8 2 / 8 2 / 2

Unit suites

packages/react-router, vitest run (typecheck enabled), full suite:

Branch Baseline Patched
main 74 files, 993 passed / 3 failed / 1 skipped (997) 74 files, 996 passed / 1 skipped (997)
1.170.17 45 files, 914 passed / 3 failed / 1 skipped (918) 45 files, 917 passed / 1 skipped (918)

The 3 baseline failures are the probe's own count assertions, i.e. the red side
of this change. No other test changes state, and Type Errors no errors on
both sides of both branches.

Error-boundary reset semantics

This is the behaviour the change most risks, since it moves what feeds
getResetKey. Two tests cover it, and both pass before and after on both
branches (so they are guards, not new behaviour being introduced):

  1. a layout that throws during render resets its boundary on the next
    navigation
    — the throwing layout stays mounted across the navigation, so
    a resetKey change is the only thing that can clear the boundary.
  2. error thrown by a staying layout after the last commit still resets on the
    next navigation
    — the timing edge: the component renders fine, then a later
    setState makes it throw after the last store/loadedAt tick. The next
    navigation must still reset.

The repo's existing errorComponent.test.tsx,
disableGlobalCatchBoundary.test.tsx and not-found.test.tsx also pass
unchanged.

One sizing note

An app that sets a router-level defaultErrorComponent resolves an
errorComponent for every route, so ResettableCatchBoundary mounts once per
match rather than only for routes that declare their own error component. That is
bounded: the residual cost is the wrapper + CatchBoundary pair, and the subtree
below still bails on element identity (props.children is the element
MatchView already created), so the per-match render savings this PR delivers are
unaffected. If you want the residual per-match subscription gone even in the
default-error-component case, the direction would be CatchBoundary reading its
reset signal only while it is actually errored — that is deliberately out of scope
here and would be a follow-up rather than something to fold into this change.

Benchmarks

Strictly interleaved A/B, never A×n then B×n — sequential batching on this
box invents 10–15% swings. Each round: swap the built @tanstack/react-router
dist for the other side, rebuild the benchmark app, run the bench. Medians
across rounds; vitest bench time: 10_000, warmupIterations: 100.

benchmarks/client-nav (react), 8 interleaved rounds per side

Metric baseline median patched median Δ baseline range patched range paired wins
throughput (hz, higher better) 57.26 58.94 +2.9% 53.73–59.54 57.50–60.20 6/8
mean (ms, lower better) 17.47 16.97 −2.9% 16.80–18.61 16.61–17.39 6/8
min (ms, lower better) 14.17 14.06 −0.8% 13.98–14.57 13.95–14.18 5/8

Honest reading: the median moves the right way and the patched side is
visibly tighter, but the harness cannot cleanly resolve an effect this size. The
round-to-round spread of the baseline against itself is ~10%, wider than the
~3% median delta, and 2 of 8 paired rounds went the other way (sign test on 6/8
is not significant). Machine: 4 vCPU, load average 0.7–0.9 from unrelated
background work, per-run rme 0.8–1.2%. Treat CodSpeed on CI as the deciding
measurement; this is not a claim of a 3% win.

benchmarks/ssr (react), 4 interleaved rounds per side

Metric baseline median patched median Δ
throughput (hz) 43.20 43.44 +0.6%
mean (ms) 23.15 23.02 −0.6%
min (ms) 20.02 19.93 −0.5%

No measurable change, which is expected: the fix targets re-render churn
across client navigations, and SSR renders each match once. Reported to show
there is no regression, not a win. (benchmarks/memory was not run.)

Never traded runtime for bytes: nothing in this change adds work to a render
path in order to shrink output.

Bundle size

Bundle size is a review gate here, so: gzip first (gzip -9), then raw, then
brotli (quality 11), per emitted file. Measured on packages/react-router/dist
from vite build. The build of the unmodified tag is byte-identical to the
published 1.170.17 tarball across all 299 dist files
, so these deltas are
attributable to the source change and nothing else.

Attribution is per hunk, on the 1.170.17 line where the change splits cleanly
into the loadedAt hunk and the field-selection hunk (the selector-only column
is full − loadedAt):

File Metric baseline loadedAt hunk selector hunk both
esm/Match.js gzip 2878 +24 +71 +95
esm/Match.js raw 12912 +138 −141 −3
esm/Match.js brotli 2554 +25 +75 +100
cjs/Match.cjs gzip 3011 +25 +69 +94
cjs/Match.cjs raw 14909 +210 −246 −36
cjs/Match.cjs brotli 2662 +36 +66 +102
whole dist/esm JS gzip 41020 +24 +71 +95
whole dist/cjs JS gzip 46177 +25 +69 +94

esm/index.js, esm/index.dev.js and cjs/index.cjs are byte-identical in
every variant — the change is contained to the Match chunk.

So: +95 B gzip on the esm payload (+0.23% of 41.0 kB), and raw output
actually shrinks, because retiring the key useMemo and the two dead
isServer branches removes more code than the wrapper adds.

Tuples over objects

The MatchInner/Match selection publishes a labelled tuple, not an
object. Measured both, same logic:

File Metric object selection labelled tuple tuple saves
esm/Match.js gzip 3001 2973 28 B
esm/Match.js raw 13073 12909 164 B
cjs/Match.cjs gzip 3130 3105 25 B
cjs/Match.cjs raw 15037 14873 164 B

One build-output note worth knowing

The production build keeps /** … *\/ JSDoc blocks in dist and drops //
line comments. Writing the ResettableCatchBoundary rationale as a JSDoc block
cost +335 B gzip instead of +95 B — the comment was 70% of the change's
gzip footprint. Internal, non-exported helpers here therefore document
themselves with //.

Dead code removed

  • MatchView's resetKey prop and its type (1.170.x) — the boundary owns it now.
  • MatchInner's key useMemo and its six-entry dependency array (both
    branches) — the key is computed in the store selector.
  • 1.170.x only, two unreachable server branches inside MatchInner's
    client path: the if (isServer ?? router.isServer) guard around
    minPendingPromise, and the whole server status === 'error' branch.
    MatchInner has its own if (isServer ?? router.isServer) { … return }
    block far above that returns before either is reachable. (On main the
    server error branch is live — MatchInner has no separate server block
    there — so it is kept.)

No other parameter or local became unused; eslint is clean on the changed
files.

Solid

The same pattern does not apply, and no fix is needed. Not implemented.

packages/solid-router/src/Match.tsx has no component-level re-render to
suppress. currentMatch is a createMemo over the same per-route store, and
every consumer — Show, Switch, Dynamic, the getResetKey passed to
CatchBoundary — is its own fine-grained reactive computation. A fresh match
identity re-runs only the computations that read it, and only patches DOM where
the derived value actually changed. There is no MatchView/MatchInner
subtree that gets invalidated wholesale, so there is nothing for a field
selection to save.

Two details worth noting for reviewers:

  • Solid's getResetKey={currentMatch} is the direct analogue of this PR's
    ResettableCatchBoundary, and it already costs nothing extra: it is a signal
    read inside the boundary, not a re-render of every match.
  • Solid deliberately depends on the whole match identity in exactly one place,
    and its comment names the reason: "Lazy route option mutations become
    observable with the next client match publication."
    That is precisely the
    dependency the React port had to re-add as the route._lazy selection slot —
    independent confirmation that it is a real render input rather than a
    workaround.

How to review

  1. Read selectMatchFields and ask whether anything MatchView/MatchInner
    renders is missing from the tuple. That is the whole correctness argument.
  2. Read ResettableCatchBoundary and confirm the two error-reset tests in the
    probe file are the ones you would have asked for — especially the second, the
    "error tripped after the last store tick" edge.
  3. Everything else is mechanical: props threaded as primitives instead of a
    match object.

Summary by CodeRabbit

  • Bug Fixes

    • Reduced unnecessary component re-renders during navigation, improving routing performance.
    • Preserved correct behavior for pending, not-found, error, and server-rendered routes.
    • Improved error-boundary reset behavior so it occurs only when appropriate.
    • Maintained correct component remounting during route transitions.
  • Tests

    • Added coverage for navigation rendering, route updates, repeated navigations, route-store changes, and error-boundary resets.

agent added 3 commits August 6, 2026 19:15
Counts MatchImpl/MatchInnerImpl renders and match-store publishes across
three navigations, and asserts that a route error boundary still resets on
the next navigation. Reporting only for now.
Match selected its whole match by identity. buildMatches re-mints every
staying match on every navigation -- a fresh object with a fresh
_strictSearch, context and abortController and an updated cause -- so the
store always republished with a new identity and every persisted route
re-rendered MatchView, its Suspense/Catch boundaries and MatchInner.

Select a tuple of the fields that actually reach the output. loaderDeps,
_strictParams and _strictSearch only feed remountDeps, so the remount key is
computed in the selector and compared as a string, which retires the
MatchInner key useMemo that never held. MatchInner now takes primitives, so
React.memo can bail out.

CatchBoundary resets its error whenever getResetKey() changes, and the match
identity was what supplied that. Observing it in a thin wrapper that is only
rendered when the route resolves an errorComponent keeps the per-navigation
update off every other mounted route; props.children is the element MatchView
already created, so the subtree below bails out.

route._lazy is selected as well: a lazy route's options are assigned onto the
route object in place, and a re-offered pending match is the only signal that
the components this subtree renders have just been replaced.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f8bddc4-eae6-4f49-8816-0f6126e5cab3

📥 Commits

Reviewing files that changed from the base of the PR and between 0a24b3d and 9b6318c.

📒 Files selected for processing (1)
  • packages/react-router/tests/match-rerender-probe.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-router/tests/match-rerender-probe.test.tsx

📝 Walkthrough

Walkthrough

React Router now selects rendered route-match fields and compares selections to reduce unnecessary rerenders. Error-boundary reset tracking is separate from normal match rendering. New probes cover navigation render counts and error-boundary reset timing.

Changes

Route-match rendering optimization

Layer / File(s) Summary
Selective match-state subscription
packages/react-router/src/Match.tsx
Match selects rendered fields, lazy-route state, SSR state, and remount dependencies. Equality comparison filters unrelated match-store updates.
Selected rendering and error reset flow
packages/react-router/src/Match.tsx
Match, MatchView, and MatchInner receive selected state. Routes with an errorComponent use ResettableCatchBoundary, which observes match identity for resets.
Render and boundary regression probes
packages/react-router/tests/match-rerender-probe.test.tsx, .changeset/mean-pugs-play.md
Tests cover sibling, search-only, repeated-route, and error-boundary navigation. The changeset records the patch release behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Navigation
  participant matchStore
  participant Match
  participant ResettableCatchBoundary
  participant MatchInner
  Navigation->>matchStore: publish navigation match state
  Match->>matchStore: select rendered match fields
  matchStore-->>Match: return changed selection only when relevant
  Match->>ResettableCatchBoundary: pass match identity for reset tracking
  ResettableCatchBoundary->>MatchInner: render selected status, error, and remountKey
Loading

Possibly related PRs

  • TanStack/router#7805 — Both PRs modify packages/react-router/src/Match.tsx and error-boundary reset behavior.
  • TanStack/router#7948 — Both PRs modify React Router match handling and related match-store APIs.
  • TanStack/router#7983 — Both PRs modify Match.tsx to refine store subscriptions and rerender behavior.

Suggested labels: package: react-router

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary performance change: preventing persisted React Router matches from re-rendering during navigation.
Linked Issues check ✅ Passed The changes address the reported unnecessary rerenders and preserve status, error, remount, lazy-route, and error-boundary reset behavior [#7989].
Out of Scope Changes check ✅ Passed The implementation, regression tests, and changeset all directly support the linked issue and stated performance objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
packages/react-router/tests/match-rerender-probe.test.tsx (2)

18-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use braces for every control statement.

Wrap each one-line if and for body in curly braces.

As per coding guidelines, “Always use curly braces for if, else, loops, and similar control statements.”

Also applies to: 42-42, 47-49, 304-304, 361-361

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-router/tests/match-rerender-probe.test.tsx` at line 18, Update
the control statements in match-rerender-probe.test.tsx, including the guards
around origMemo and the locations at the referenced lines, so every if and for
body is enclosed in curly braces. Preserve each condition and body behavior
while applying braces consistently to all affected one-line and multi-line
control statements.

Source: Coding guidelines


17-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove any from the test helpers.

packages/react-router uses the root strict flag, but this test escapes function fnName {}-style type checks at lines 17-27, 46-55, and 131-156. Type the memo wrapper and store instrumentation with narrow interfaces, generics, or unknown with guards; keep useRouterState selector inference intact instead of adding an explicit parameter type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-router/tests/match-rerender-probe.test.tsx` around lines 17 -
27, Remove all any usage from the test helpers, including the memo wrapper and
instrumentation around the referenced render-count and function-name checks.
Replace it with narrow interfaces, generics, or unknown plus appropriate type
guards, while preserving the existing memo behavior and useRouterState selector
inference without adding an explicit selector parameter type.

Sources: Coding guidelines, Learnings

packages/react-router/src/Match.tsx (2)

237-253: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the duplicated MatchInner element.

Both branches construct MatchInner with the same four props. A single local element removes the duplication and prevents the two prop lists from diverging later.

♻️ Proposed refactor
+  const matchInner = (
+    <MatchInner
+      routeId={routeId}
+      status={status}
+      error={error}
+      remountKey={remountKey}
+    />
+  )

Then use it in both branches:

               {resolvedNoSsr ? (
                 <ClientOnly fallback={pendingElement}>
-                  <MatchInner
-                    routeId={routeId}
-                    status={status}
-                    error={error}
-                    remountKey={remountKey}
-                  />
+                  {matchInner}
                 </ClientOnly>
               ) : (
-                <MatchInner
-                  routeId={routeId}
-                  status={status}
-                  error={error}
-                  remountKey={remountKey}
-                />
+                matchInner
               )}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-router/src/Match.tsx` around lines 237 - 253, In the render
scope containing the resolvedNoSsr conditional, hoist the shared MatchInner
element with routeId, status, error, and remountKey into a single local
variable, then reuse it directly and inside ClientOnly while preserving
pendingElement as the fallback.

85-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add curly braces to the early return.

The coding guidelines forbid one-line bodies for if.

♻️ Proposed fix
-  if (!match) return emptyMatchSelection
+  if (!match) {
+    return emptyMatchSelection
+  }

As per coding guidelines: "Always use curly braces for if, else, loops, and similar control statements. Never write one-line bodies like if (foo) x = 1."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-router/src/Match.tsx` at line 85, Update the early-return
conditional in the Match component/function so the if (!match) statement uses
curly braces around its return body, preserving the existing emptyMatchSelection
behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react-router/src/Match.tsx`:
- Around line 267-283: Forward route._lazy from MatchSelection through MatchView
into MatchInner, and include the forwarded lazy value in the relevant
render/useMemo dependencies so lazy resolution invalidates the memo boundary and
replaces the resolved component.

In `@packages/react-router/tests/match-rerender-probe.test.tsx`:
- Around line 222-244: In the search-navigation test around the router.navigate
call and report assertions, first verify that the rendered link or navigation
target href contains tab=mine, confirming the navigation completed and updated
router state. Then retain the existing MatchImpl and MatchInnerImpl zero-render
assertions.

---

Nitpick comments:
In `@packages/react-router/src/Match.tsx`:
- Around line 237-253: In the render scope containing the resolvedNoSsr
conditional, hoist the shared MatchInner element with routeId, status, error,
and remountKey into a single local variable, then reuse it directly and inside
ClientOnly while preserving pendingElement as the fallback.
- Line 85: Update the early-return conditional in the Match component/function
so the if (!match) statement uses curly braces around its return body,
preserving the existing emptyMatchSelection behavior.

In `@packages/react-router/tests/match-rerender-probe.test.tsx`:
- Line 18: Update the control statements in match-rerender-probe.test.tsx,
including the guards around origMemo and the locations at the referenced lines,
so every if and for body is enclosed in curly braces. Preserve each condition
and body behavior while applying braces consistently to all affected one-line
and multi-line control statements.
- Around line 17-27: Remove all any usage from the test helpers, including the
memo wrapper and instrumentation around the referenced render-count and
function-name checks. Replace it with narrow interfaces, generics, or unknown
plus appropriate type guards, while preserving the existing memo behavior and
useRouterState selector inference without adding an explicit selector parameter
type.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b0838e11-f524-4562-bb2f-c1d84fc819ff

📥 Commits

Reviewing files that changed from the base of the PR and between abf9b81 and 0a24b3d.

📒 Files selected for processing (3)
  • .changeset/mean-pugs-play.md
  • packages/react-router/src/Match.tsx
  • packages/react-router/tests/match-rerender-probe.test.tsx

Comment on lines 267 to +283
export const MatchInner = React.memo(function MatchInnerImpl({
match,
routeId,
status,
error,
remountKey,
}: {
match: AnyRouteMatch
routeId: string
status: AnyRouteMatch['status'] | undefined
error: unknown
remountKey: string | undefined
}): any {
const router = useRouter()
const routeId = match.routeId
const route = router.routesById[routeId] as AnyRoute
const key = React.useMemo(() => {
const remountFn =
route.options.remountDeps ?? router.options.defaultRemountDeps
const remountDeps = remountFn?.({
routeId,
loaderDeps: match.loaderDeps,
params: match._strictParams,
search: match._strictSearch,
})
return remountDeps ? JSON.stringify(remountDeps) : undefined
}, [
routeId,
match.loaderDeps,
match._strictParams,
match._strictSearch,
route.options.remountDeps,
router.options.defaultRemountDeps,
])
const out = React.useMemo(() => {
const Comp = route.options.component ?? router.options.defaultComponent
return Comp ? <Comp key={key} /> : <Outlet />
}, [key, route.options.component, router.options.defaultComponent])
return Comp ? <Comp key={remountKey} /> : <Outlet />
}, [remountKey, route.options.component, router.options.defaultComponent])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Trace _lazy assignment and match status updates around lazy route loading.
set -euo pipefail

echo "=== _lazy assignments and reads across the repository ==="
rg -n -C4 '\b_lazy\b' --glob '!**/dist/**' --glob '!**/*.md'

echo "=== Lazy option assignment onto route.options ==="
ast-grep run --pattern 'Object.assign($ROUTE.options, $$$)' --lang typescript packages || true

echo "=== Status transitions written by the loader ==="
fd -t f 'load.ts' packages/router-core/src | xargs -r rg -n -C3 "status: '(pending|success|error|notFound)'"

Repository: TanStack/router

Length of output: 211


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Candidate Match.tsx ==="
fd -a 'Match\.tsx$' . | sed 's#^\./##'

echo "=== Locate selectMatchFields and MatchView/MatchInner ==="
rg -n "selectMatchFields|MatchView|MatchInner|function MatchInnerImpl|React\.memo\(|selection|_lazy|lazy:" --glob '!**/dist/**' --glob '!**/*.md' .

echo "=== List router-core src files ==="
fd -t f . packages/router-core/src | sed 's#^\./##' | head -200

Repository: TanStack/router

Length of output: 18822


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Match.tsx relevant sections ==="
cat -n packages/react-router/src/Match.tsx | sed -n '45,175p'
cat -n packages/react-router/src/Match.tsx | sed -n '180,315p'

echo "=== Load-client lazy implementation ==="
cat -n packages/router-core/src/load-client.ts | sed -n '1,115p'
cat -n packages/router-core/src/load-client.ts | sed -n '845,885p'

echo "=== Router store update / transaction application references ==="
rg -n -C5 "apply.*transaction|transactions|_tx|router\.state|status: 'pending'|status: 'success'|pending|success" packages/router-core/src/packages/react-router/src --glob '!**/dist/**'

Repository: TanStack/router

Length of output: 16667


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Match.tsx MatchInner tail ==="
cat -n packages/react-router/src/Match.tsx | sed -n '308,320p'

echo "=== Router transaction/update/status path ==="
rg -n -C4 "status: 'pending'|status: 'success'|replaceRouteChunk|loadRouteChunk|onReady:|options\[8\]|status =" packages/router-core/src packages/react-router/src --glob '!**/dist/**'

echo "=== Read-only behavioral probe: current _lazy selection and MatchInner props ==="
python3 - <<'PY'
from pathlib import Path
p = Path('packages/react-router/src/Match.tsx')
text = p.read_text()
checks = {
    'MatchSelection_has_lazy_field': "lazy: LazyRouteState," in text and "a[5] === b[5]" in text,
    'selectMatchFields_returns_lazy': "   (route as { _lazy?: LazyRouteState })._lazy," in text,
    'MatchView_destructures_lazy': "_lazy" in text.split("function MatchView")[1].split("const pendingElement")[0],
    'MatchInner_renders_lazy_prop': "lazy={lazy}" in text,
    'MatchInner_declares_lazy_prop': "lazy," in text.split("export const MatchInner")[1].split("): any {")[0],
    'MatchInner useMemo uses lazy': "'lazy'" in text.split("export const MatchInner")[1] or '"lazy"' in text.split("export const MatchInner")[1] or "lazy," in text.split("export const MatchInner")[1] or " lazy" in text.split("export const MatchInner")[1],
}
for name, ok in checks.items():
    print(f"{name}: {ok}")
PY

Repository: TanStack/router

Length of output: 47448


The _lazy selection is not consumed by the render memo boundary.

MatchSelection includes route._lazy, but MatchView discards it and MatchInner only receives status, error, and remountKey. When lazy resolution happens on an already-success match, React updates the parent selections but can bail out of MatchInner; the useMemo then keeps the old component because it only depends on remountKey, route.options.component, and router.options.defaultComponent. Forward the lazy state into MatchInner and observe it there so lazy chunk replacement invalidates the memo boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-router/src/Match.tsx` around lines 267 - 283, Forward
route._lazy from MatchSelection through MatchView into MatchInner, and include
the forwarded lazy value in the relevant render/useMemo dependencies so lazy
resolution invalidates the memo boundary and replaces the resolved component.

Comment thread packages/react-router/tests/match-rerender-probe.test.tsx
… assertions

The search-only probe test asserted only that MatchImpl/MatchInnerImpl rendered
zero times. Those assertions would also pass if the navigation had silently done
nothing, so add an href check confirming the search actually changed before
asserting it caused no re-renders (per CodeRabbit review feedback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines +77 to +79
// `route._lazy` is selected too: a lazy route's options are assigned onto the
// route in place, and a re-offered pending match is the only signal that the
// components this subtree renders have just been replaced.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't something else also change at the same time when the components are replaced? like the status for example?
This feels quite hacky, and "bolted on top", i'm not super convinced

Comment on lines +98 to +101
match.id,
match.ssr,
match.status,
match.error,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just return the match? since we're already doing a custom equality function, we can restrict the equality check to those specific keys without having to then pass them all as individual props to the MatchInner

Comment on lines +62 to +69
const emptyMatchSelection: MatchSelection = [
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't just [] do the same thing?

@Sheraff Sheraff left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If i understand correctly, we're doing all of this so that <Comp /> does not re-render? Or just so that MatchView and MatchInner don't re-render?

Because I think that

  • Comp does not re-render (it is memoized on very narrow and stable dependencies)
  • MatchView and MatchInner are probably pretty cheap to re-render, aren't they?
    • MatchInner is 2 useMemo and 3 if
    • MatchView seems fairly trivial as well, no logic, just some JSX

Do we get noticeable performance benefits from this change?

@matclayton
matclayton marked this pull request as draft August 6, 2026 23:16
@matclayton

Copy link
Copy Markdown
Author

Thanks for looking at it, I'm also not convinced right now, unlike the Link PR. I'm still profiling it internally and seeing if its a meaningful speed up. We have some slowness in this area, but its not clear to me this is actually solving the main issue, it helps, but it might be negligible. I'll look in over it the next day or so, I thought I'd marked it as draft for now, my mistake!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every persisted route match re-renders on every navigation

2 participants