Skip to content

perf(react-router): bail out of Link re-renders when href and active state are unchanged - #7952

Open
matclayton wants to merge 9 commits into
TanStack:mainfrom
matclayton:link-rerender-bailout
Open

perf(react-router): bail out of Link re-renders when href and active state are unchanged#7952
matclayton wants to merge 9 commits into
TanStack:mainfrom
matclayton:link-rerender-bailout

Conversation

@matclayton

@matclayton matclayton commented Aug 4, 2026

Copy link
Copy Markdown

Fixes #7951.

Problem

useLinkProps subscribes every client-side Link to the location store with an identity selector:

const currentLocation = useStore(
  router.stores.location,
  (l) => l,
  (prev, next) => prev.href === next.href,
)

href and isActive are then derived in downstream memos that list currentLocation in their dependencies. So the comparator can only ask "is this a different URL?", never "does this link care?" — and every Link on the page re-renders on every navigation, including the ones whose rendered output is identical before and after.

On one page in our app (Mixcloud), 313 links persisted across a single navigation and exactly 1 changed its rendered output — the tab gaining data-status="active". The other 312 re-rendered for nothing. Full measurements are in #7951.

This is complementary to #2359 / #2516, which reduced the cost of each Link render via the routesByPath fast path. This reduces the number of renders. After #2516 each render is cheaper, but every link still renders on every navigation — the remaining buildLocation time measured at 15.8ms across 1563 calls, with the residue being the React render pass itself.

Change

The location-derived values move into the selector, and compareLinkState compares the three resulting primitives, so a link whose href and active state are unaffected by a navigation bails out.

buildLocation still runs once per link per location change. What goes away is the React render and the host reconciliation beneath it.

Supporting details:

  • The isActive and externalLink bodies move to module-level helpers unchanged, so the selector stays readable.
  • activeOptions is spread into its four primitive fields in the selector's dependency list rather than depended on directly, because callers routinely pass an inline object literal — this matches what the previous isActive memo already did.
  • doPreload no longer pre-supplies _builtLocation, because the built location is no longer held in render state. preloadRoute already falls back to opts._builtLocation ?? this.buildLocation(opts), and handleClick has always let router.navigate build its own. Net cost is one extra buildLocation per hover.

The subscribed-location pinning is preserved deliberately. Link passes _fromLocation: currentLocation, and in buildLocation that is the head of dest._fromLocation || this.pendingBuiltLocation || this.latestLocation — pinning resolution to the subscribed snapshot rather than one that can differ mid-transition. The selector formulation keeps that, because it derives from the value being published. A naive "subscribe to a boolean" would not, since next still needs the location for relative to resolution and param inheritance.

Tests

tests/link.test.tsxlink re-render bail-out. It renders two memoized components that call useLinkProps, navigates, and asserts that the link the navigation cannot affect does not re-render. The components are memoized so a re-render of the owning route component cannot be mistaken for the subscription firing, and the link options are module-stable for the same reason.

Verified in both directions by reverting the source change and keeping the test:

  • without the change: AssertionError: expected 4 to be 2 (one re-render, doubled by StrictMode)
  • with the change: passes

Existing suite, against the unmodified baseline on the same commit:

baseline this branch
test:unit 943 passed, 1 skipped 944 passed, 1 skipped
test:types passes passes
test:eslint 0 errors, 97 warnings 0 errors, 97 warnings
test:build passes passes

Scope

Only packages/react-router. packages/solid-router and packages/vue-router have the same shape and would want the same treatment — I have not touched them, so please don't assume they're covered. Happy to follow up on those if you'd like it done the same way.

tests/link.bench.tsx exists and I did not run it; it looks like the natural home for a perf guard here if you want one.

Summary by CodeRabbit

  • Bug Fixes
    • Improved link updates during navigation so active links refresh correctly.
    • Reduced unnecessary re-rendering for links whose destination and active state remain unchanged.
    • Improved handling of external URLs and blocked unsafe protocols.
    • Enhanced route preloading consistency by rebuilding destination details when needed.
    • Links now provide more reliable navigation behavior as the application’s location changes.

…state are unchanged

useLinkProps subscribes to the location store with an identity selector and an
href comparator, then derives href and isActive from the published location in
downstream memos. The comparator can only ask "is this a different URL?", never
"does this link care?", so every Link on the page re-renders on every navigation.

Move the location-derived values into the selector and compare them, so a link
whose resolved href and active state are unaffected by a navigation bails out.
buildLocation still runs once per link per location change; what goes away is the
React render and the host reconciliation under it.

doPreload no longer pre-supplies _builtLocation, because the built location is no
longer kept in render state. preloadRoute already falls back to building it, which
is what handleClick has always relied on for router.navigate.

The isActive and externalLink bodies move to module-level helpers unchanged so the
selector stays readable; activeOptions is spread into its four primitive fields in
the dependency list because callers routinely pass an inline object literal.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

useLinkProps now selects and compares derived link state from the location store. Link helpers centralize URL and active-state handling. Preloading rebuilds locations internally. Tests verify selective re-rendering during navigation.

Changes

Link state subscription

Layer / File(s) Summary
Derive and publish link state
packages/react-router/src/link.tsx
Link helpers resolve external URLs, block dangerous protocols, and calculate active state. useLinkProps selects and compares href, externalLink, and isActive. Preloading no longer passes a cached built location.
Validate selective link renders
packages/react-router/tests/link.test.tsx
Added memoized link fixtures and a regression test. Navigation re-renders the link whose active state changes but not an unrelated link.

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

Sequence Diagram(s)

sequenceDiagram
  participant LocationStore
  participant LinkProps
  participant Router
  participant Anchor
  LocationStore->>LinkProps: publish location
  LinkProps->>Router: build destination location
  Router-->>LinkProps: return selected link state
  LinkProps-->>Anchor: provide href and active state
  Anchor->>Anchor: render when selected state changes
Loading

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 and concisely describes the Link re-render optimization implemented by the pull request.
Linked Issues check ✅ Passed The changes address issue #7951 by selector-comparing derived href and active state and adding regression coverage for skipped renders.
Out of Scope Changes check ✅ Passed The changes remain within packages/react-router and its tests, matching the linked issue scope.
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.

🧹 Nitpick comments (2)
packages/react-router/tests/link.test.tsx (1)

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

Assert the published link state, not only the render counts.

The test proves that the unaffected link does not re-render. It does not prove that the selector still publishes correct values. A selector that returned a constant LinkState would also pass. Add assertions on the active status of becomesActive and on the href of unaffected after navigation.

💚 Proposed additional assertions
     // `/posts` gains its active state, so it has to re-render.
     expect(renderCounts.becomesActive).toBeGreaterThan(before.becomesActive)
+    expect(screen.getByTestId('becomesActive')).toHaveAttribute(
+      'data-status',
+      'active',
+    )
 
     // `/elsewhere` is neither the origin nor the destination: its href and
     // active state are identical before and after, so the subscription must
     // bail out rather than publish an equal value.
     expect(renderCounts.unaffected).toBe(before.unaffected)
+    expect(screen.getByTestId('unaffected')).toHaveAttribute(
+      'href',
+      '/elsewhere',
+    )
+    expect(screen.getByTestId('unaffected')).not.toHaveAttribute('data-status')
   })
🤖 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/link.test.tsx` around lines 7615 - 7632, Extend
the test around the existing becomesActive and unaffected link references to
assert published state after navigation: verify becomesActive is active and
verify unaffected retains the expected href. Keep the render-count assertions,
ensuring the test validates both selector values and the unaffected link’s
bailout behavior.
packages/react-router/src/link.tsx (1)

76-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add braces to the single-line if bodies and drop the redundant cast.

Lines 76, 77, and 101 use one-line if bodies. The coding guidelines require curly braces for all if statements. At line 79, to is already narrowed to string by the guard at line 77, so as any removes type information without need.

♻️ Proposed style fix
-  if (isSafeInternal(to)) return undefined
-  if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined
+  if (isSafeInternal(to)) {
+    return undefined
+  }
+  if (typeof to !== 'string' || to.indexOf(':') === -1) {
+    return undefined
+  }
   try {
-    new URL(to as any)
+    new URL(to)

Apply the same change at line 101:

-  if (isExternal) return false
+  if (isExternal) {
+    return false
+  }

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

🤖 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/link.tsx` around lines 76 - 79, Update the relevant
conditionals in the link handling flow, including the guards around
isSafeInternal and the string check and the conditional at the later indicated
location, to use curly-braced bodies. In the new URL validation, remove the
unnecessary “as any” cast because the preceding typeof guard narrows to to as a
string.

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.

Nitpick comments:
In `@packages/react-router/src/link.tsx`:
- Around line 76-79: Update the relevant conditionals in the link handling flow,
including the guards around isSafeInternal and the string check and the
conditional at the later indicated location, to use curly-braced bodies. In the
new URL validation, remove the unnecessary “as any” cast because the preceding
typeof guard narrows to to as a string.

In `@packages/react-router/tests/link.test.tsx`:
- Around line 7615-7632: Extend the test around the existing becomesActive and
unaffected link references to assert published state after navigation: verify
becomesActive is active and verify unaffected retains the expected href. Keep
the render-count assertions, ensuring the test validates both selector values
and the unaffected link’s bailout behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f721a164-9e52-4f8c-9625-fc7274c2309c

📥 Commits

Reviewing files that changed from the base of the PR and between 314098e and 702eefa.

📒 Files selected for processing (2)
  • packages/react-router/src/link.tsx
  • packages/react-router/tests/link.test.tsx

The comments explained the bail-out rationale twice — once on the LinkState type
and again above the selector — and two helper docblocks restated their function
names. The rationale now appears once, where a reader meets the selector; the
detail belongs in the PR description rather than the source.

Also braces the three single-line if bodies, per the AGENTS.md rule that if/else
bodies always use curly braces.
@nx-cloud

nx-cloud Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 48dc687

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 13m 10s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 2m 15s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-06 07:56:33 UTC

@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

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

@tanstack/eslint-plugin-router

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

@tanstack/eslint-plugin-start

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-rsc

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

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

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: 48dc687

@codspeed-hq

codspeed-hq Bot commented Aug 4, 2026

Copy link
Copy Markdown

Merging this PR will regress 5 benchmarks

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 16 improved benchmarks
❌ 5 regressed benchmarks
✅ 159 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation client-control-flow navigation loop (react) 66.1 ms 72.3 ms -8.66%
Simulation client-head navigation loop (react) 76.2 ms 82.3 ms -7.51%
Memory mem server request-churn (react) 491.6 KB 528.6 KB -6.99%
Memory mem server error-paths redirect (react) 197 KB 208.7 KB -5.61%
Simulation ssr assets linked-css control (vue) 186.1 ms 193.9 ms -4.01%
Memory mem server error-paths unmatched (vue) 2,142.1 KB 478.2 KB ×4.5
Simulation client-links navigation loop (react) 204.8 ms 98.3 ms ×2.1
Memory mem server peak-large-page (react) 1,737.5 KB 952.9 KB +82.34%
Memory mem server error-paths redirect (solid) 388 KB 273.7 KB +41.76%
Simulation client-route-tree-scale navigation loop (react) 72.3 ms 55.3 ms +30.92%
Simulation client-side navigation loop (react) 47.8 ms 41.2 ms +16.13%
Simulation client-preload interaction loop (react) 53.4 ms 49.1 ms +8.72%
Memory mem server error-paths not-found (react) 277.7 KB 255.7 KB +8.58%
Simulation client-search-params navigation loop (react) 80.9 ms 75.8 ms +6.67%
Simulation client-nested-params navigation loop (react) 76.7 ms 73.4 ms +4.52%
Memory mem client navigation-churn (vue) 1.3 MB 1.2 MB +3.83%
Simulation ssr control-flow unmatched 404 (react) 58.1 ms 56.4 ms +3.06%
👁 Memory mem server error-paths unmatched (react) 317.5 KB 268.9 KB +18.08%
👁 Memory mem server error-paths redirect (vue) 338.6 KB 300 KB +12.86%
👁 Memory mem server server-fn-churn (vue) 4,147.3 KB 264.3 KB ×16
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing matclayton:link-rerender-bailout (48dc687) with main (697ebb6)

Open in CodSpeed

@nx-cloud nx-cloud 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.

Important

At least one additional CI pipeline execution has run since the conclusion below was written and it may no longer be applicable.

Nx Cloud has identified a possible root cause for your failed CI:

We classified this failure as environment_state because the failing task (tanstack-solid-start-e2e-basic:test:e2e--vite-preview) belongs to the solid-start E2E suite, while this PR exclusively modifies @tanstack/react-router. The test asserting window.SCRIPT_1 === true after client-side navigation is a solid-start script-injection concern with no connection to the Link re-render bail-out changes introduced here.

No code changes were suggested for this issue.

You can trigger a rerun by pushing an empty commit:

git commit --allow-empty -m "chore: trigger rerun"
git push

Nx Cloud View detailed reasoning on Nx Cloud ↗


🎓 Learn more about Self-Healing CI on nx.dev

Comment on lines +616 to +617
// state. Matches `handleClick`, which lets `router.navigate` build its own.
router.preloadRoute({ ..._options } as any).catch((err) => {

@Sheraff Sheraff Aug 5, 2026

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 return the built location from selectLinkState and use it here? It's a cheap way to bypass a bunch of work (thouh admittedly "preload" work happens less frequently than "location update" re-renders)
(maybe it's not possible without causing a re-render on every location change, just asking in case it is. This might also be the only place in the repo where we use _builtLocation as a param for preloadRoute, so if we really don't use it anymore, we might be able to clean that code path up)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I don't think it can work, for the reason you suspected. Two ways to try it:

  • Include next in the compared state. Its identity changes on every location change, so the comparator is always false and every link re-renders on every navigation. That removes the point of the PR.
  • Return it but leave it out of the comparator. When the comparator reports equal, useSyncExternalStoreWithSelector hands back the previous selection, so next is stale. Harmless for an absolute to, wrong for relative to and inherited params: you'd preload a location built from the old one, silently.

On cleaning up the code path, not yet: _builtLocation is still passed to preloadRoute by packages/solid-router/src/link.tsx:254 and packages/vue-router/src/link.tsx:255. It'd only become removable once those get the same treatment, which I've deliberately left alone here.

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.

once we've confirmed this change is acceptable for React, I think it should also be applied to Solid and Vue to limit drift. And then if what you are saying is correct, we will be able to remove _builtLocation which saves some bytes again

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Happy to have a go at those but it'll be much more AI driven and less me steering, as honestly I'm not a Vue/solid person, would you like them on this PR?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I looked at both. Two findings, and they both point away from the plan.

The change does not port, because the problem is React specific. What this PR removes is a React component re-render: useLinkProps re-runs and the whole <a> subtree reconciles on every navigation. Solid and Vue have no such thing. In Solid, useLinkProps runs once per link and next / hrefOption / isActive are createMemos (packages/solid-router/src/link.tsx:136-203), so a location change recomputes those memos and updates only the bound attributes. Vue is the same shape with computed and a computedProps ref built in setup() (packages/vue-router/src/link.tsx:226-244, 427-449). Neither re-runs the component body, so there is no re-render to bail out of.

Both also already gate on the href the same way this PR's base did, via the location store's equality option: solid-router/src/link.tsx:128-131 and vue-router/src/link.tsx:222-224. Converting either to a tuple selector would do the same work at coarser granularity, and would lose the per-attribute update that fine-grained reactivity gives them. I would leave both alone.

_builtLocation cannot be removed. It is core redirect plumbing rather than a link optimisation, so it survives regardless of what the adapters do:

  • router-core/src/router.ts:2437 throws redirect({ href, _builtLocation: nextLocation }) from core itself.
  • resolveRedirect reads it at router.ts:2804-2806 to skip rebuilding the location.
  • router.ts:2825 gates the dangerous-protocol check on its absence, so a redirect carrying an internally built location deliberately skips validation that an externally supplied href receives. Removing the field would change that security behaviour.

The only piece that becomes dead if all three adapters stop passing it is the opts._builtLocation ?? fallback in preloadRoute (router.ts:2891), one line. The type, the redirect plumbing and the protocol gate all stay.

Worth adding that Solid and Vue pass an already memoized next() into preloadRoute, so for them the parameter is a genuine saving. Dropping it there would be a pessimisation, which is the opposite of the byte win. React is the odd one out here only because the selector cannot safely publish next (my earlier comment in this thread).

Comment thread packages/react-router/src/link.tsx Outdated
Comment thread packages/react-router/src/link.tsx Outdated
Comment on lines +499 to +504
const {
exact: activeExact,
explicitUndefined: activeExplicitUndefined,
includeHash: activeIncludeHash,
includeSearch: activeIncludeSearch,
} = activeOptions ?? {}

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.

I think (needs to be checked) that we would save a few bytes by not destructuring here, and just

  • using property access in the dependencies array
      activeOptions.exact,
      activeOptions.explicitUndefined,
      activeOptions.includeHash,
      activeOptions.includeSearch,
  • and not re-building the object when calling resolveIsActive
      resolveIsActive(
        //...,
        activeOptions,
        //...,
      )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Tried this: lint fails. Passing activeOptions straight through to resolveIsActive makes the body reference the whole object, so react-hooks/exhaustive-deps then wants it in the dependency array:

542:5  error  React Hook React.useCallback has a missing dependency: 'activeOptions'  react-hooks/exhaustive-deps

The destructure keeps the deps as primitives while the body still gets an object. Also worth noting the snippet needs activeOptions?.exact rather than activeOptions.exact, since activeOptions is optional and most links don't pass it, so the unguarded access throws on the common path.

Happy to switch if listing activeOptions itself is preferable. The cost is that callers passing an inline activeOptions={{ exact: true }} literal rebuild the selector on every render. That doesn't cause extra re-renders, since the comparator still returns the previous selection; it just re-runs buildLocation on renders that were happening anyway. Either trade works for me, just say which you'd rather have.

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.

it's ok to disable eslint locally if we can save some bytes (and we know that it is safe to do so)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Can re-check this in a bit, just vendoring this into our internal test suite to check if it finds any regressions.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 07081d4. activeOptions now goes straight to resolveIsActive, with the dependency array listing the four fields and an exhaustive-deps disable above it.

Saves 269 bytes on each of dist/esm/link.js and dist/cjs/link.cjs, unminified.

Kept the ?. on the dependency entries, since activeOptions is undefined on most links and the unguarded access throws on that path.

The type is erased either way, but the object literal's property names survive
minification and a tuple's positions don't — so this drops three property names
from the selector's return plus the three property reads in compareLinkState.

Measured on the unminified build: -54 bytes in dist/esm/link.js and the same in
dist/cjs/link.cjs.
The render-count assertions proved the bail-out but not that the selector still
publishes correct values, so a selector returning a constant could have passed.
The test now also asserts that the link gaining active state carries
`data-status="active"` afterwards (and does not beforehand), and that the
unaffected link keeps its href and stays inactive.

Checked by sabotaging the selector: returning a constant tuple with a wrong href
but a correct active state now fails, where previously it passed.

Also drops `as any` from `new URL(to)` in resolveExternalLink — the guard above
already narrows `to` to string.
@matclayton

Copy link
Copy Markdown
Author

Pushed bd4e5b7, 2ec281e and 8d04b0b.

Both nitpicks are addressed: braces on the single-line if bodies, and as any dropped from new URL(to) since the guard already narrows to to string. The test now asserts the published state as well as the render counts; I confirmed that adds coverage by sabotaging the selector, where a constant tuple with a wrong href but correct active state now fails.

On CodSpeed: most regressions are Solid and Vue benchmarks, and this PR only touches packages/react-router/src/link.tsx, so they can't come from it. The memory numbers aren't plausible either (mem server server-fn-churn (vue): 269.5 KB to 4,658.6 KB), and CodSpeed's banner says different runtime environments were compared.

The four React client-nav regressions are in scope, so I checked whether the selector runs buildLocation more often than the old useMemo. It halves it: 917 calls before, 459 after, same page and link counts. I can't rule out that those scenarios have few enough links that the bail-out has nothing to pay for while the selector still costs per render. Do you trust those numbers on this runner? If so I'll dig in; if they're known noisy I'd rather not chase them.

@Sheraff

Sheraff commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

On CodSpeed: most regressions are Solid and Vue benchmarks, and this PR only touches packages/react-router/src/link.tsx, so they can't come from it. The memory numbers aren't plausible either (mem server server-fn-churn (vue): 269.5 KB to 4,658.6 KB), and CodSpeed's banner says different runtime environments were compared.

yes memory benchmarks should be ignored, i haven't managed to stabilize them yet.

The four React client-nav regressions are in scope, so I checked whether the selector runs buildLocation more often than the old useMemo. It halves it: 917 calls before, 459 after, same page and link counts. I can't rule out that those scenarios have few enough links that the bail-out has nothing to pay for while the selector still costs per render. Do you trust those numbers on this runner? If so I'll dig in; if they're known noisy I'd rather not chase them.

Some CPU benchmarks are more stable than others, so to take with a grain of salt. ssr request loop (solid) is supposed to be one of the stable ones, and it appears here as a regression on code that was not touched so...

All the benchmarks can be run locally though, where you have more control over stability. That includes bundle size benchmarks and all the codspeed benchmarks (that just run on vitest locally).

There is also e2e/react-start/flamegraph-bench for benchmarks that really hammer the SSR server, but this PR is purely client-side, right?

Depends on the four fields rather than the object, with an exhaustive-deps
disable: callers routinely pass an inline literal, which would otherwise rebuild
the selector every render. resolveIsActive reads only those four fields, so the
disable is not hiding a live dependency.

-269 bytes on each of dist/esm/link.js and dist/cjs/link.cjs (unminified).
The `useMemo` chain this replaced keyed `getHrefOption` and the external-link
resolution on the href string, so a navigation that left a link's href alone
skipped both. Deriving everything in the selector ran them on every location
notification instead, which showed up as a ~10% regression on the client-nav
rewrites benchmark, where rewrite handling makes `getHrefOption` expensive.

Cache both on the built href inside the selector closure. Measured on a
five-link root layout, per navigation: getHrefOption drops from 5 calls back to
0, matching the pre-change profile, with buildLocation and the active-state
derivation unchanged at 5.
Links commonly pass inline `params` / `search` object literals. Those change
identity on every parent render, which rebuilt `_options`, which changed the
store selector's identity, which discarded useSyncExternalStoreWithSelector's
memoized selection. buildLocation then ran twice per navigation: once in the
notification check and once in the render-phase selection.

Measured on a replica of the client-nav rewrites scenario (six links, root
subscribed to the pathname via useLocation), buildLocation per navigation:
base 7, before this commit 12, after 7.
Reverts 409371b. It did cut getHrefOption from 5 calls per navigation to 0,
matching the pre-change profile, but that is not where the time went: on the
rewrites scenario it moved the number by 0.05% (medians 245.33 vs 245.21 hz over
four interleaved rounds). Not worth ~15 lines of mutable closure state.

The rewrites regression is fixed by the _options stabilisation instead.
Replaces a leftover scratch note.
@matclayton

Copy link
Copy Markdown
Author

Ran client-nav locally as suggested, patched and base interleaved, three rounds per side, comparing medians. Interleaving mattered: run sequentially, the numbers moved by up to 10 points on scenarios where the code does provably identical work, so the first pass manufactured regressions that were really machine drift.

Two of the four React regressions (head, control-flow) come out as improvements locally. rewrites reproduced, and it was real.

The rewrites regression was real, and is fixed

Links commonly pass inline params={{ a: 'x' }} / search={{ _locale: 'fr' }} literals. Those change identity on every parent render, which rebuilt _options, which changed the store selector's identity, which discarded useSyncExternalStoreWithSelector's memoized selection. buildLocation then ran twice per navigation, once in the notification check and once in the render-phase selection, where the previous useMemo chain ran it once.

Measured on a replica of the rewrites scenario (six links, root subscribed to the pathname via useLocation, inline params/search), buildLocation calls per navigation:

base this PR before the fix this PR now
per navigation 7 12 7

The arithmetic pins it: the one link with no inline props builds once, the five with inline props build twice. Fixed by keeping _options referentially stable while its contents are equal.

Current numbers

Medians of three interleaved runs, hz, higher is better:

scenario base this PR delta
client-links 62.3 121.1 +94.2%
client-side 252.4 324.2 +28.4%
client-route-tree-scale 186.4 218.7 +17.3%
client-async-pipeline 312.5 348.9 +11.7%
client-head 201.4 224.7 +11.6%
client-loaders 302.2 332.9 +10.1%
client-search-params 137.3 151.1 +10.0%
client-nested-params 165.2 175.9 +6.5%
client-preload 279.0 296.8 +6.4%
client-control-flow 152.1 160.5 +5.5%
client-rewrites 238.8 247.0 +3.5%
client-history 360.2 356.3 -1.1%
client-mount 614.5 583.7 -5.0%

One regression left, and I have not explained it

client-mount is still about -5%. It reproduces with the first commit alone, so it comes from the core change rather than any of the refinements, but I have not isolated the mechanism: buildLocation counts are identical at mount (7 on both sides), and a standalone mount loop plus CPU profile showed parity, though that harness turned out to be timer bound and not sensitive enough to trust. Flagging it rather than claiming it is noise.

The CodSpeed report above is stale

The workflow runs for the latest commits are sitting in action_required, so Benchmarks and Bundle Size have not re-run since 8d04b0b. The CodSpeed comment still reflects the state before the rewrites fix. Whenever you next approve a run it should re-measure.

Bundle size

Against base, from benchmark:bundle-size: +20 to +28 bytes raw and -7 to +27 bytes gzip across the eight React scenarios.

Also dropped a commit along the way. I had added an href memoization that cut getHrefOption from 5 calls per navigation to 0, which looked like the fix. It moved the rewrites benchmark by 0.05%, so it was reverted rather than carried for the sake of a tidy call-count.

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.

Link re-renders on every navigation even when its href and active state are unchanged

2 participants