perf(react-router): fewer hooks per Link - #8404
schiller-manuel merged 1 commit into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
💤 Files with no reviewable changes (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review. 📝 WalkthroughWalkthroughThe link implementation consolidates stable option values, returns a copied destination, and replaces the shared intersection hook with inline preload handling for render, viewport, delayed, and touch-triggered preloads. ChangesLink stability and preloading
Priority: ⬇️ Low Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Refactor Suggested reviewers: Merge Risk: ⚪ Minimal · up to The Link refactor preserves the reviewed preload and identity behaviors, with no current merge-blocking risk identified. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 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 |
|
View your CI Pipeline Execution ↗ for commit 0f01bdc
☁️ Nx Cloud last updated this comment at |
🚀 Changeset Version Preview7 package(s) bumped directly, 22 bumped as dependents. 🟩 Patch bumps
|
Bundle Size Benchmarks
The following scenarios have bundle-size changes compared with the baseline:
Current gzip tracks all emitted client JS chunks. Initial gzip tracks only the entry/import graph. Trend sparkline is historical current gzip ending with this PR measurement; lower is better. |
| } | ||
| return () => { | ||
| observer?.disconnect() | ||
| enqueuePreload() |
There was a problem hiding this comment.
why do we enqueuePreload on cleanup?
There was a problem hiding this comment.
My bad, it is also the cleanup function
if (!e) {
cancelPreload(innerRef)
return
}A Link render ran about 25 hook slots. React allocates a hook object per slot per render, plus a deps array for every memo, callback and effect, so the slots set both the re-render cost and the retained fiber memory of a mounted Link. Five of them did not need to exist: - The three `useValueStable` refs for `search`, `params` and `activeOptions` become one `useStableValues` ref that replaces each entry only when its own contents change, so identities behave exactly as before. - `_options` and its `dest` copy come out of one memo instead of two. - `doPreload` is a module function, `preloadLink(router, options)`; the callback existed only to give the effects a stable identity. - The IntersectionObserver effect and the render-preload effect merge into one preload effect whose cleanup also cancels a pending intent timer, as the observer hook's cleanup did. `useIntersectionObserver` had no other consumer and is removed. `hasRenderFetched` stays: without it a link with an updater-function `search` would preload on every render. Measurements (macOS arm64, Node 24.8.0, local, 200 mounted Links): - retained heap per mounted app 2949 -> 2782 KB (-6%, about -0.8 KB per Link) - mount (router creation + first render) median 2.05 -> 1.90 ms - Link client paired runner (4 repeats): shared-params CPU -3.0% [-5.3, -0.6], active -3.3% [-8.4, 2.1] - react-router.minimal gzip 85984 -> 85968 (-16) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
b63c749 to
0f01bdc
Compare
A Link render ran about 25 hook slots. React allocates a hook object per slot per render, plus a deps array for every memo, callback and effect, so the slots set both the re-render cost and the retained fiber memory of a mounted Link. Five of them did not need to exist: - The three `useValueStable` refs for `search`, `params` and `activeOptions` become one `useStableValues` ref that replaces each entry only when its own contents change, so identities behave exactly as before. - `_options` and its `dest` copy come out of one memo instead of two. - `doPreload` is a module function, `preloadLink(router, options)`; the callback existed only to give the effects a stable identity. - The IntersectionObserver effect and the render-preload effect merge into one preload effect whose cleanup also cancels a pending intent timer, as the observer hook's cleanup did. `useIntersectionObserver` had no other consumer and is removed. `hasRenderFetched` stays: without it a link with an updater-function `search` would preload on every render. Measurements (macOS arm64, Node 24.8.0, local, 200 mounted Links): - retained heap per mounted app 2949 -> 2782 KB (-6%, about -0.8 KB per Link) - mount (router creation + first render) median 2.05 -> 1.90 ms - Link client paired runner (4 repeats): shared-params CPU -3.0% [-5.3, -0.6], active -3.3% [-8.4, 2.1] - react-router.minimal gzip 85984 -> 85968 (-16) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Stacked on #8401.
🎯 Changes
A
Linkrender ran about 25 hook slots. React allocates a hook object per slot per render, plus a deps array for everyuseMemo/useCallback/useEffect, so the slot count sets both the re-render cost and the retained fiber memory of a mountedLink. Five of those slots did not need to exist:useValueStablerefs forsearch,paramsandactiveOptionsbecome oneuseStableValuesref. Each entry is replaced only when its own contents change, so the returned identities behave exactly as before (including theignoreUndefined: falsesemantics that distinguish{}from{ category: undefined })._optionsand itsdestcopy come out of oneuseMemoinstead of two.doPreloadbecomes a module functionpreloadLink(router, options); theuseCallbackexisted only to give the effects a stable identity.useIntersectionObserverinutils.tshad no other consumer in@tanstack/react-routerand is removed (thesolid-router/vue-routerpackages keep their own separate copies).hasRenderFetchedstays: without it a link with an updater-functionsearchwould preload on every render.Public API is unchanged. Touches only
packages/react-router/src/link.tsxandpackages/react-router/src/utils.ts(+60/−97).Measurements (local, macOS arm64, Node 24.8.0, 200 mounted Links, vs. the previous commit)
react-router.minimalgzip: 85984 → 85968 (−16 bytes)Bundle-content assertions (verified locally)
Built with
TSR_LINK_PERF=1 pnpm nx run-many --target=build:ssr,build:client --projects=@benchmarks/react-link-performance --skipNxCache:benchmarks/client-nav/link-performance/dist/ssr/app.js: contains nostaticLocations, nouseIntersectionObserver, and does containgetServerLinkProps✅benchmarks/client-nav/link-performance/dist/client/app.js: containsstaticLocations, nogetServerLinkProps, nouseIntersectionObserver✅Tests run (all with
--skipNxCache)@tanstack/react-router:test:unit— 91 files, 1174 passed, 1 skipped, no type errors@tanstack/react-router:test:types— passes on TS 5.6, 5.7, 5.8, 5.9, 6.0 and 7.0@tanstack/react-router:test:eslint— 0 errors, 100 pre-existing warnings (none inlink.tsxorutils.ts)git diff --check HEAD~1— cleanpnpm exec prettier --check packages/react-router/src/link.tsx packages/react-router/src/utils.ts— clean✅ Checklist
🚀 Release Impact
.changeset/brisk-links-serve.mdfrom the lower layers of this stack already declares a@tanstack/react-routerpatch and covers this change; no new changeset is added.Summary by CodeRabbit