Skip to content

perf(react-router): fewer hooks per Link - #8404

Merged
schiller-manuel merged 1 commit into
schiller-manuel-link-one-key-set-layerfrom
schiller-manuel-link-fewer-hooks-layer
Sep 13, 2026
Merged

schiller-manuel merged 1 commit into
schiller-manuel-link-one-key-set-layerfrom
schiller-manuel-link-fewer-hooks-layer

Conversation

@schiller-manuel

@schiller-manuel schiller-manuel commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #8401.

🎯 Changes

A Link render ran about 25 hook slots. React allocates a hook object per slot per render, plus a deps array for every useMemo/useCallback/useEffect, so the slot count sets both the re-render cost and the retained fiber memory of a mounted Link. Five of those slots did not need to exist:

  1. The three useValueStable refs for search, params and activeOptions become one useStableValues ref. Each entry is replaced only when its own contents change, so the returned identities behave exactly as before (including the ignoreUndefined: false semantics that distinguish {} from { category: undefined }).
  2. _options and its dest copy come out of one useMemo instead of two.
  3. doPreload becomes a module function preloadLink(router, options); the useCallback existed only to give the effects a stable identity.
  4. The IntersectionObserver effect and the render-preload effect merge into one preload effect whose cleanup also cancels a pending intent timer, exactly as the observer hook's cleanup did. useIntersectionObserver in utils.ts had no other consumer in @tanstack/react-router and is removed (the solid-router/vue-router packages keep their own separate copies).

hasRenderFetched stays: without it a link with an updater-function search would preload on every render.

Public API is unchanged. Touches only packages/react-router/src/link.tsx and packages/react-router/src/utils.ts (+60/−97).

Measurements (local, macOS arm64, Node 24.8.0, 200 mounted Links, vs. the previous commit)

  • 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] faster; active −3.3% [−8.4, 2.1] inconclusive
  • react-router.minimal gzip: 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 no staticLocations, no useIntersectionObserver, and does contain getServerLinkProps
  • benchmarks/client-nav/link-performance/dist/client/app.js: contains staticLocations, no getServerLinkProps, no useIntersectionObserver

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:eslint0 errors, 100 pre-existing warnings (none in link.tsx or utils.ts)
  • git diff --check HEAD~1 — clean
  • pnpm exec prettier --check packages/react-router/src/link.tsx packages/react-router/src/utils.ts — clean

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset. — The existing changeset .changeset/brisk-links-serve.md from the lower layers of this stack already declares a @tanstack/react-router patch and covers this change; no new changeset is added.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Performance
    • Improved link preloading behavior for render, viewport, delayed, and touch interactions.
    • Improved stability when link destinations, search values, parameters, and active options change.

@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: c499d405-5556-4b24-b12e-9ad1d2e21ec5

📥 Commits

Reviewing files that changed from the base of the PR and between 2a299e2 and b63c749.

📒 Files selected for processing (2)
  • packages/react-router/src/link.tsx
  • packages/react-router/src/utils.ts
💤 Files with no reviewable changes (1)
  • packages/react-router/src/utils.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The 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.

Changes

Link stability and preloading

Layer / File(s) Summary
Stable link options
packages/react-router/src/link.tsx
useStableValues replaces separate stable-value hooks. The options memo returns both options and a copied destination.
Preload execution flow
packages/react-router/src/link.tsx, packages/react-router/src/utils.ts
Preloading uses preloadLink for render, delayed, viewport, and touch paths. Inline effect logic manages intersection observation and cleanup. useIntersectionObserver is removed.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Suggested reviewers: sheraff

Merge Risk: ⚪ Minimal · up to 0f01b

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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 1 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: reducing hook usage in the React Router Link component.
Description check ✅ Passed The description follows the required template, explains the changes and motivation, documents testing and measurements, completes the checklist, and identifies the release impact and existing changese…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch schiller-manuel-link-fewer-hooks-layer

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.

@nx-cloud

nx-cloud Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 0f01bdc

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 16m 21s View ↗

☁️ Nx Cloud last updated this comment at 2026-09-12 23:12:26 UTC

@schiller-manuel
schiller-manuel added this pull request to stack #8346 September 12, 2026 14:07
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

7 package(s) bumped directly, 22 bumped as dependents.

🟩 Patch bumps

Package Version Reason
@tanstack/history 1.162.3 → 1.162.4 Changeset
@tanstack/react-router 1.170.35 → 1.170.36 Changeset
@tanstack/router-core 1.171.29 → 1.171.30 Changeset
@tanstack/router-devtools-core 1.168.1 → 1.168.2 Changeset
@tanstack/solid-router 1.170.33 → 1.170.34 Changeset
@tanstack/start-server-core 1.169.34 → 1.169.35 Changeset
@tanstack/vue-router 1.170.32 → 1.170.33 Changeset
@tanstack/react-router-devtools 1.167.1 → 1.167.2 Dependent
@tanstack/react-start 1.168.52 → 1.168.53 Dependent
@tanstack/react-start-client 1.168.33 → 1.168.34 Dependent
@tanstack/react-start-rsc 0.1.51 → 0.1.52 Dependent
@tanstack/react-start-server 1.167.40 → 1.167.41 Dependent
@tanstack/router-cli 1.167.35 → 1.167.36 Dependent
@tanstack/router-devtools 1.167.1 → 1.167.2 Dependent
@tanstack/router-generator 1.167.35 → 1.167.36 Dependent
@tanstack/router-plugin 1.168.37 → 1.168.38 Dependent
@tanstack/router-vite-plugin 1.167.37 → 1.167.38 Dependent
@tanstack/solid-router-devtools 1.167.1 → 1.167.2 Dependent
@tanstack/solid-start 1.168.50 → 1.168.51 Dependent
@tanstack/solid-start-client 1.168.32 → 1.168.33 Dependent
@tanstack/solid-start-server 1.167.39 → 1.167.40 Dependent
@tanstack/start-client-core 1.170.29 → 1.170.30 Dependent
@tanstack/start-plugin-core 1.171.42 → 1.171.43 Dependent
@tanstack/start-static-server-functions 1.167.34 → 1.167.35 Dependent
@tanstack/start-storage-context 1.167.31 → 1.167.32 Dependent
@tanstack/vue-router-devtools 1.167.1 → 1.167.2 Dependent
@tanstack/vue-start 1.168.49 → 1.168.50 Dependent
@tanstack/vue-start-client 1.167.35 → 1.167.36 Dependent
@tanstack/vue-start-server 1.167.39 → 1.167.40 Dependent

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Benchmarks

  • Commit: c22f5f4eb66a
  • Measured at: 2026-09-12T22:55:51.166Z
  • Baseline source: history:ae6853592904
  • Dashboard: bundle-size history

The following scenarios have bundle-size changes compared with the baseline:

Scenario Current (gzip) Initial (gzip) Raw Brotli Trend
react-router.minimal 83.9 KiB
-30 B
83.8 KiB
-31 B
261.6 KiB
-737 B
73.2 KiB
-40 B
█████▂▂▂▂▂▂▁
react-router.full 87.5 KiB
+12 B
87.3 KiB
+12 B
273.3 KiB
-713 B
76.2 KiB
+63 B
█████▂▂▂▂▂▁▂
solid-router.minimal 33.4 KiB
-30 B
33.3 KiB
-30 B
95.9 KiB
-833 B
30.3 KiB
+24 B
▆█████████▇▁
solid-router.full 38.3 KiB
-1 B
38.2 KiB
-2 B
110.6 KiB
-832 B
34.5 KiB
+70 B
▃█████████▂▁
vue-router.minimal 49.5 KiB
-88 B
49.4 KiB
-87 B
137.1 KiB
-1.2 KiB
44.8 KiB
-101 B
█████▃▃▃▃▃▃▁
vue-router.full 55.1 KiB
-89 B
55.0 KiB
-88 B
155.3 KiB
-1.2 KiB
49.7 KiB
-49 B
█████▃▃▃▃▃▃▁
react-start.minimal 96.9 KiB
+17 B
96.8 KiB
+16 B
303.9 KiB
-730 B
84.1 KiB
+37 B
█████▁▁▁▁▁▁▂
react-start.query-integration 104.2 KiB
-9 B
104.1 KiB
-9 B
330.4 KiB
-739 B
90.4 KiB
+64 B
█████▂▂▂▂▂▂▁
react-start.deferred-hydration 97.6 KiB
+29 B
96.8 KiB
+32 B
305.2 KiB
-730 B
84.7 KiB
-58 B
█████▁▁▁▁▁▁▃
react-start.full 100.1 KiB
+8 B
99.9 KiB
+5 B
313.6 KiB
-707 B
86.7 KiB
-1 B
█████▁▁▁▁▁▁▁
react-start.rsbuild.minimal 100.2 KiB
+53 B
100.0 KiB
+53 B
314.3 KiB
-579 B
86.5 KiB
+12 B
█████▁▁▁▁▁▁▄
react-start.rsbuild.minimal-iife 100.6 KiB
+58 B
100.4 KiB
+58 B
315.2 KiB
-562 B
86.9 KiB
+68 B
█████▁▁▁▁▁▁▄
react-start.rsbuild.full 103.5 KiB
+51 B
103.3 KiB
+51 B
324.4 KiB
-536 B
89.2 KiB
-12 B
█████▁▁▁▁▁▁▃
solid-start.minimal 46.4 KiB
+33 B
46.2 KiB
+31 B
137.1 KiB
-833 B
41.3 KiB
+82 B
▃▂▂▂▂▂▂▂▂▂▁█
solid-start.deferred-hydration 49.4 KiB
+11 B
46.3 KiB
+13 B
144.4 KiB
-831 B
44.0 KiB
+72 B
▁▃▃▃▃▃▃▃▃▃▅█
solid-start.full 51.4 KiB
+26 B
51.3 KiB
+23 B
152.5 KiB
-831 B
45.6 KiB
+74 B
▃▂▂▂▂▂▂▂▂▂▁█
vue-start.minimal 65.6 KiB
-101 B
65.5 KiB
-100 B
188.0 KiB
-1.2 KiB
58.5 KiB
+50 B
█████▃▃▃▃▃▃▁
vue-start.full 69.5 KiB
-67 B
69.4 KiB
-64 B
200.3 KiB
-1.2 KiB
61.8 KiB
+15 B
█████▂▂▂▂▂▂▁

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.

@pkg-pr-new

pkg-pr-new Bot commented Sep 12, 2026

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@8404

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@8404

@tanstack/eslint-plugin-start

npm i https://pkg.pr.new/@tanstack/eslint-plugin-start@8404

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@8404

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@8404

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@8404

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@8404

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@8404

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@8404

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@8404

@tanstack/react-start-rsc

npm i https://pkg.pr.new/@tanstack/react-start-rsc@8404

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@8404

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@8404

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@8404

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@8404

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@8404

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@8404

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@8404

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@8404

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@8404

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@8404

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@8404

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@8404

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@8404

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@8404

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@8404

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@8404

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@8404

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@8404

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@8404

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@8404

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@8404

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@8404

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@8404

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@8404

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@8404

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@8404

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@8404

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@8404

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@8404

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@8404

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@8404

commit: 0f01bdc

@codspeed

codspeed Bot commented Sep 12, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 180 untouched benchmarks


Comparing schiller-manuel-link-fewer-hooks-layer (0f01bdc) with schiller-manuel-link-one-key-set-layer (ef6726a)

Open in CodSpeed

}
return () => {
observer?.disconnect()
enqueuePreload()

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 do we enqueuePreload on cleanup?

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.

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>
@schiller-manuel
schiller-manuel force-pushed the schiller-manuel-link-fewer-hooks-layer branch from b63c749 to 0f01bdc Compare September 12, 2026 21:35
@schiller-manuel
schiller-manuel merged commit d740d8b into main Sep 13, 2026
26 checks passed
@schiller-manuel
schiller-manuel deleted the schiller-manuel-link-fewer-hooks-layer branch September 13, 2026 11:55
Sheraff pushed a commit that referenced this pull request Sep 14, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants