perf(react-router): stop persisted matches re-rendering on every navigation - #7990
perf(react-router): stop persisted matches re-rendering on every navigation#7990matclayton wants to merge 4 commits into
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughReact 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. ChangesRoute-match rendering optimization
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
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
packages/react-router/tests/match-rerender-probe.test.tsx (2)
18-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse braces for every control statement.
Wrap each one-line
ifandforbody 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 winRemove
anyfrom the test helpers.
packages/react-routeruses the root strict flag, but this test escapesfunction fnName {}-style type checks at lines 17-27, 46-55, and 131-156. Type the memo wrapper and store instrumentation with narrow interfaces, generics, orunknownwith guards; keepuseRouterStateselector 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 valueHoist the duplicated
MatchInnerelement.Both branches construct
MatchInnerwith 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 winAdd 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 likeif (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
📒 Files selected for processing (3)
.changeset/mean-pugs-play.mdpackages/react-router/src/Match.tsxpackages/react-router/tests/match-rerender-probe.test.tsx
| 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]) |
There was a problem hiding this comment.
🎯 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 -200Repository: 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}")
PYRepository: 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.
… 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>
| // `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. |
There was a problem hiding this comment.
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
| match.id, | ||
| match.ssr, | ||
| match.status, | ||
| match.error, |
There was a problem hiding this comment.
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
| const emptyMatchSelection: MatchSelection = [ | ||
| undefined, | ||
| undefined, | ||
| undefined, | ||
| undefined, | ||
| undefined, | ||
| undefined, | ||
| ] |
There was a problem hiding this comment.
wouldn't just [] do the same thing?
There was a problem hiding this comment.
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
Compdoes not re-render (it is memoized on very narrow and stable dependencies)MatchViewandMatchInnerare probably pretty cheap to re-render, aren't they?MatchInneris 2useMemoand 3ifMatchViewseems fairly trivial as well, no logic, just some JSX
Do we get noticeable performance benefits from this change?
|
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! |
perf(react-router): stop persisted matches re-rendering on every navigation
Fixes the churn described in the accompanying issue (#7989): every mounted
MatchandMatchInnerre-rendered on every navigation, including matches thatstayed and rendered identical output.
Closes #7989.
Branch:
match-rerender-main(3 commits, offabf9b81b1f).What changed
packages/react-router/src/Match.tsxonly.1.
Matchselects the fields its subtree renders, instead of the whole matchby identity.
The selection is a labelled tuple of
[matchId, ssr, status, error, remountKey, lazy].loaderDeps,_strictParamsand_strictSearchonly reach the outputthrough
remountDeps, so the remount key is computed inside the selector andcompared as a string — which also retires the
MatchInnerkeyuseMemo,which never held anyway (a fresh
_strictSearchobject invalidated it everynavigation).
MatchInnernow receives primitives, so itsReact.memocanactually bail out.
2. The per-navigation identity that resets
CatchBoundaryis observed in awrapper that only mounts for routes that have an
errorComponent.getResetKey={() => match}was the whole reasonMatchneeded the matchidentity. Moving that read down means only routes that can actually show an
error pay for it, and because
props.childrenis the elementMatchViewalready created, the subtree below the wrapper bails out even when the wrapper
itself re-renders.
3.
route._lazyis part of the selection. A lazy route's options areObject.assigned onto the route object in place, and a re-offered pending matchis 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 pendingfails (it keeps showingLoading defaultinstead ofLoading lazy page)._lazyis@internal, so itis stripped from the published declarations and is read through a narrow local
structural type.
Red / green counts
Counted by patching
React.memoto wrap every*Implcomponent with a counter(
packages/react-router/tests/match-rerender-probe.test.tsx, added in the firstcommit as reporting-only and asserted in the third). Four matches mounted, three
of them staying across a sibling navigation.
main(this PR)MatchImplbeforeMatchInnerImplbefore/section/list/a → /b?tab=all → ?tab=mineOutletImplis unchanged (1 / 0 / 2) — the fix does not suppress renders thatare genuinely required.
Store-side counts, unchanged by the fix because
router-coreis untouched:3 staying-match publishes per sibling navigation, 0 of which are structurally
equal to the previous value.
Released
1.170.17line, for referenceThe same shape of fix applies there, and it splits into two independently
measurable hunks.
Matchon 1.170.x also subscribes torouter.stores.loadedAtpurely for
getResetKey.loadedAthunk onlyMatchImpl/MatchInnerImplMatchImpl/MatchInnerImplMatchImpl/MatchInnerImplUnit suites
packages/react-router,vitest run(typecheck enabled), full suite:main1.170.17The 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 errorsonboth 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 bothbranches (so they are guards, not new behaviour being introduced):
navigation — the throwing layout stays mounted across the navigation, so
a resetKey change is the only thing that can clear the boundary.
next navigation — the timing edge: the component renders fine, then a later
setStatemakes it throw after the last store/loadedAttick. The nextnavigation must still reset.
The repo's existing
errorComponent.test.tsx,disableGlobalCatchBoundary.test.tsxandnot-found.test.tsxalso passunchanged.
One sizing note
An app that sets a router-level
defaultErrorComponentresolves anerrorComponentfor every route, soResettableCatchBoundarymounts once permatch rather than only for routes that declare their own error component. That is
bounded: the residual cost is the wrapper +
CatchBoundarypair, and the subtreebelow still bails on element identity (
props.childrenis the elementMatchViewalready created), so the per-match render savings this PR delivers areunaffected. If you want the residual per-match subscription gone even in the
default-error-component case, the direction would be
CatchBoundaryreading itsreset 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-routerdistfor the other side, rebuild the benchmark app, run the bench. Mediansacross rounds;
vitest benchtime: 10_000,warmupIterations: 100.benchmarks/client-nav(react), 8 interleaved rounds per sideHonest 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
rme0.8–1.2%. Treat CodSpeed on CI as the decidingmeasurement; this is not a claim of a 3% win.
benchmarks/ssr(react), 4 interleaved rounds per sideNo 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/memorywas 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, thenbrotli (quality 11), per emitted file. Measured on
packages/react-router/distfrom
vite build. The build of the unmodified tag is byte-identical to thepublished 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.17line where the change splits cleanlyinto the
loadedAthunk and the field-selection hunk (the selector-only columnis
full − loadedAt):loadedAthunkesm/Match.jsesm/Match.jsesm/Match.jscjs/Match.cjscjs/Match.cjscjs/Match.cjsdist/esmJSdist/cjsJSesm/index.js,esm/index.dev.jsandcjs/index.cjsare byte-identical inevery variant — the change is contained to the
Matchchunk.So: +95 B gzip on the esm payload (+0.23% of 41.0 kB), and raw output
actually shrinks, because retiring the
keyuseMemoand the two deadisServerbranches removes more code than the wrapper adds.Tuples over objects
The
MatchInner/Matchselection publishes a labelled tuple, not anobject. Measured both, same logic:
esm/Match.jsesm/Match.jscjs/Match.cjscjs/Match.cjsOne build-output note worth knowing
The production build keeps
/** … *\/JSDoc blocks indistand drops//line comments. Writing the
ResettableCatchBoundaryrationale as a JSDoc blockcost +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'sresetKeyprop and its type (1.170.x) — the boundary owns it now.MatchInner'skeyuseMemoand its six-entry dependency array (bothbranches) — the key is computed in the store selector.
MatchInner'sclient path: the
if (isServer ?? router.isServer)guard aroundminPendingPromise, and the whole serverstatus === 'error'branch.MatchInnerhas its ownif (isServer ?? router.isServer) { … return }block far above that returns before either is reachable. (On
maintheserver error branch is live —
MatchInnerhas no separate server blockthere — so it is kept.)
No other parameter or local became unused;
eslintis clean on the changedfiles.
Solid
The same pattern does not apply, and no fix is needed. Not implemented.
packages/solid-router/src/Match.tsxhas no component-level re-render tosuppress.
currentMatchis acreateMemoover the same per-route store, andevery consumer —
Show,Switch,Dynamic, thegetResetKeypassed toCatchBoundary— is its own fine-grained reactive computation. A fresh matchidentity re-runs only the computations that read it, and only patches DOM where
the derived value actually changed. There is no
MatchView/MatchInnersubtree that gets invalidated wholesale, so there is nothing for a field
selection to save.
Two details worth noting for reviewers:
getResetKey={currentMatch}is the direct analogue of this PR'sResettableCatchBoundary, and it already costs nothing extra: it is a signalread inside the boundary, not a re-render of every match.
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._lazyselection slot —independent confirmation that it is a real render input rather than a
workaround.
How to review
selectMatchFieldsand ask whether anythingMatchView/MatchInnerrenders is missing from the tuple. That is the whole correctness argument.
ResettableCatchBoundaryand confirm the two error-reset tests in theprobe file are the ones you would have asked for — especially the second, the
"error tripped after the last store tick" edge.
match object.
Summary by CodeRabbit
Bug Fixes
Tests