From 702eefa545b0730689023ef428d3993e4860fcdd Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Tue, 4 Aug 2026 22:26:18 +0100 Subject: [PATCH 01/14] perf(react-router): bail out of Link re-renders when href and active 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. --- packages/react-router/src/link.tsx | 306 +++++++++++++--------- packages/react-router/tests/link.test.tsx | 91 +++++++ 2 files changed, 275 insertions(+), 122 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index eddc0ac0b3b..b2b672c58ea 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -19,9 +19,11 @@ import { useForwardedRef, useIntersectionObserver } from './utils' import { useHydrated } from './ClientOnly' import type { + ActiveOptions, AnyRouter, Constrain, LinkOptions, + ParsedLocation, RegisteredRouter, RoutePaths, } from '@tanstack/router-core' @@ -31,6 +33,107 @@ import type { ValidateLinkOptionsArray, } from './typePrimitives' +/** + * The location-derived slice of a link that the rendered output depends on. + * + * Kept to comparable primitives on purpose: it is published through + * `useStore`, and `compareLinkState` is what stops a navigation re-rendering + * every link on the page. + */ +interface LinkState { + href: string | undefined + externalLink: string | undefined + isActive: boolean +} + +function compareLinkState(a: LinkState, b: LinkState) { + return ( + a.href === b.href && + a.externalLink === b.externalLink && + a.isActive === b.isActive + ) +} + +/** + * Resolves the absolute URL a link points at, or `undefined` when it is + * internal. Also the gate for dangerous protocols. + */ +function resolveExternalLink( + hrefOption: { href: string; external?: boolean } | undefined, + to: unknown, + protocolAllowlist: AnyRouter['protocolAllowlist'], +): string | undefined { + if (hrefOption?.external) { + // Block dangerous protocols for external links + if (isDangerousProtocol(hrefOption.href, protocolAllowlist)) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${hrefOption.href}`) + } + return undefined + } + return hrefOption.href + } + if (isSafeInternal(to)) return undefined + if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined + try { + new URL(to as any) + // Block dangerous protocols like javascript:, blob:, data: + if (isDangerousProtocol(to, protocolAllowlist)) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${to}`) + } + return undefined + } + return to + } catch {} + return undefined +} + +/** Whether `next` is the location currently being viewed. */ +function resolveIsActive( + location: ParsedLocation, + next: ParsedLocation, + activeOptions: ActiveOptions | undefined, + basepath: string, + isHydrated: boolean, + isExternal: boolean, +): boolean { + if (isExternal) return false + if (activeOptions?.exact) { + const testExact = exactPathTest(location.pathname, next.pathname, basepath) + if (!testExact) { + return false + } + } else { + const currentPathSplit = removeTrailingSlash(location.pathname, basepath) + const nextPathSplit = removeTrailingSlash(next.pathname, basepath) + + const pathIsFuzzyEqual = + currentPathSplit.startsWith(nextPathSplit) && + (currentPathSplit.length === nextPathSplit.length || + currentPathSplit[nextPathSplit.length] === '/') + + if (!pathIsFuzzyEqual) { + return false + } + } + + if (activeOptions?.includeSearch ?? true) { + const searchTest = deepEqual(location.search, next.search, { + partial: !activeOptions?.exact, + ignoreUndefined: !activeOptions?.explicitUndefined, + }) + if (!searchTest) { + return false + } + } + + if (activeOptions?.includeHash) { + return isHydrated && location.hash === next.hash + } + return true +} + /** * Build anchor-like props for declarative navigation and preloading. * @@ -399,128 +502,86 @@ export function useLinkProps< ], ) + const { + exact: activeExact, + explicitUndefined: activeExplicitUndefined, + includeHash: activeIncludeHash, + includeSearch: activeIncludeSearch, + } = activeOptions ?? {} + + // Everything the rendered output derives from the location is computed inside + // the selector, so `compareLinkState` can suppress the re-render when none of + // it changed. + // + // Deriving these *after* subscribing to the whole location meant every Link on + // the page re-rendered on every navigation: the comparator could only ask "is + // this a different URL?", never "does this link care?". For all but the few + // links involved in a navigation, `href` and the active state are unchanged. // eslint-disable-next-line react-hooks/rules-of-hooks - const currentLocation = useStore( - router.stores.location, - (l) => l, - (prev, next) => prev.href === next.href, - ) - - // eslint-disable-next-line react-hooks/rules-of-hooks - const next = React.useMemo(() => { - const opts = { _fromLocation: currentLocation, ..._options } - return router.buildLocation(opts as any) - }, [router, currentLocation, _options]) - - // Use publicHref - it contains the correct href for display - // When a rewrite changes the origin, publicHref is the full URL - // Otherwise it's the origin-stripped path - // This avoids constructing URL objects in the hot path - const hrefOptionPublicHref = next.maskedLocation - ? next.maskedLocation.publicHref - : next.publicHref - const hrefOptionExternal = next.maskedLocation - ? next.maskedLocation.external - : next.external - // eslint-disable-next-line react-hooks/rules-of-hooks - const hrefOption = React.useMemo( - () => - getHrefOption( - hrefOptionPublicHref, - hrefOptionExternal, + const selectLinkState = React.useCallback( + (location: ParsedLocation): LinkState => { + const next = router.buildLocation({ + _fromLocation: location, + ..._options, + } as any) + + // Use publicHref - it contains the correct href for display + // When a rewrite changes the origin, publicHref is the full URL + // Otherwise it's the origin-stripped path + // This avoids constructing URL objects in the hot path + const hrefOption = getHrefOption( + next.maskedLocation ? next.maskedLocation.publicHref : next.publicHref, + next.maskedLocation ? next.maskedLocation.external : next.external, router.history, disabled, - ), - [disabled, hrefOptionExternal, hrefOptionPublicHref, router.history], - ) - - // eslint-disable-next-line react-hooks/rules-of-hooks - const externalLink = React.useMemo(() => { - if (hrefOption?.external) { - // Block dangerous protocols for external links - if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn( - `Blocked Link with dangerous protocol: ${hrefOption.href}`, - ) - } - return undefined - } - return hrefOption.href - } - const safeInternal = isSafeInternal(to) - if (safeInternal) return undefined - if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined - try { - new URL(to as any) - // Block dangerous protocols like javascript:, blob:, data: - if (isDangerousProtocol(to, router.protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${to}`) - } - return undefined - } - return to - } catch {} - return undefined - }, [to, hrefOption, router.protocolAllowlist]) - - // eslint-disable-next-line react-hooks/rules-of-hooks - const isActive = React.useMemo(() => { - if (externalLink) return false - if (activeOptions?.exact) { - const testExact = exactPathTest( - currentLocation.pathname, - next.pathname, - router.basepath, ) - if (!testExact) { - return false - } - } else { - const currentPathSplit = removeTrailingSlash( - currentLocation.pathname, - router.basepath, - ) - const nextPathSplit = removeTrailingSlash(next.pathname, router.basepath) - const pathIsFuzzyEqual = - currentPathSplit.startsWith(nextPathSplit) && - (currentPathSplit.length === nextPathSplit.length || - currentPathSplit[nextPathSplit.length] === '/') - - if (!pathIsFuzzyEqual) { - return false - } - } + const externalLink = resolveExternalLink( + hrefOption, + to, + router.protocolAllowlist, + ) - if (activeOptions?.includeSearch ?? true) { - const searchTest = deepEqual(currentLocation.search, next.search, { - partial: !activeOptions?.exact, - ignoreUndefined: !activeOptions?.explicitUndefined, - }) - if (!searchTest) { - return false + return { + href: hrefOption?.href, + externalLink, + isActive: resolveIsActive( + location, + next, + { + exact: activeExact, + explicitUndefined: activeExplicitUndefined, + includeHash: activeIncludeHash, + includeSearch: activeIncludeSearch, + }, + router.basepath, + isHydrated, + externalLink !== undefined, + ), } - } + }, + // `activeOptions` is spread into primitives above rather than listed whole: + // callers routinely pass an inline object literal, and depending on its + // identity would rebuild this selector on every render. + [ + activeExact, + activeExplicitUndefined, + activeIncludeHash, + activeIncludeSearch, + disabled, + isHydrated, + _options, + router, + to, + ], + ) - if (activeOptions?.includeHash) { - return isHydrated && currentLocation.hash === next.hash - } - return true - }, [ - activeOptions?.exact, - activeOptions?.explicitUndefined, - activeOptions?.includeHash, - activeOptions?.includeSearch, - currentLocation, - externalLink, - isHydrated, - next.hash, - next.pathname, - next.search, - router.basepath, - ]) + // eslint-disable-next-line react-hooks/rules-of-hooks + const { href, externalLink, isActive } = useStore( + router.stores.location, + selectLinkState, + compareLinkState, + ) // Get the active props const resolvedActiveProps: React.HTMLAttributes = isActive @@ -563,13 +624,14 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const doPreload = React.useCallback(() => { - router - .preloadRoute({ ..._options, _builtLocation: next } as any) - .catch((err) => { - console.warn(err) - console.warn(preloadWarning) - }) - }, [router, _options, next]) + // No `_builtLocation`: the built location is no longer kept in render state, + // and `preloadRoute` builds it itself (`opts._builtLocation ?? buildLocation`). + // This matches `handleClick`, which has always let `router.navigate` build it. + router.preloadRoute({ ..._options } as any).catch((err) => { + console.warn(err) + console.warn(preloadWarning) + }) + }, [router, _options]) // eslint-disable-next-line react-hooks/rules-of-hooks const preloadViewportIoCallback = React.useCallback( @@ -699,7 +761,7 @@ export function useLinkProps< ...propsSafeToSpread, ...resolvedActiveProps, ...resolvedInactiveProps, - href: hrefOption?.href, + href, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers([onClick, handleClick]), onBlur: composeHandlers([onBlur, handleLeave]), diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index f06a3a8decc..3a2e98659bd 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -31,6 +31,7 @@ import { redirect, retainSearchParams, stripSearchParams, + useLinkProps, useLoaderData, useMatchRoute, useParams, @@ -7540,3 +7541,93 @@ describe('protocolAllowlist', () => { ) }) }) + +describe('link re-render bail-out', () => { + // `useLinkProps` subscribes to the location store. Counting renders of a + // component that calls it therefore measures exactly what the subscription + // publishes: a link whose resolved href and active state are unaffected by a + // navigation should not re-render at all. + // + // The components are memoized so a re-render of the route component that owns + // them cannot be mistaken for the subscription firing, and the link options are + // module-stable for the same reason. + const stableOptions = { + unaffected: { to: '/elsewhere' } as const, + becomesActive: { to: '/posts' } as const, + } + + function setup() { + const renderCounts = { unaffected: 0, becomesActive: 0 } + + const CountingLink = React.memo(function CountingLink({ + name, + }: { + name: keyof typeof renderCounts + }) { + renderCounts[name]++ + const linkProps = useLinkProps(stableOptions[name]) + return + }) + + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + Go + + + + ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index

, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts

, + }) + + const elsewhereRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/elsewhere', + component: () =>

Elsewhere

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute, + elsewhereRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + return { router, renderCounts } + } + + test('does not re-render a link a navigation cannot affect', async () => { + const { router, renderCounts } = setup() + render() + + await screen.findByTestId('unaffected') + const before = { ...renderCounts } + + fireEvent.click(await screen.findByTestId('go')) + expect(await screen.findByText('Posts')).toBeInTheDocument() + + // `/posts` gains its active state, so it has to re-render. + expect(renderCounts.becomesActive).toBeGreaterThan(before.becomesActive) + + // `/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) + }) +}) From bd4e5b7d6c469597f6671219a3516b9293ab854e Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Tue, 4 Aug 2026 22:49:41 +0100 Subject: [PATCH 02/14] refactor: trim redundant comments and brace single-line bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/react-router/src/link.tsx | 45 +++++++++++------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index b2b672c58ea..b60f2aa499a 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -33,13 +33,6 @@ import type { ValidateLinkOptionsArray, } from './typePrimitives' -/** - * The location-derived slice of a link that the rendered output depends on. - * - * Kept to comparable primitives on purpose: it is published through - * `useStore`, and `compareLinkState` is what stops a navigation re-rendering - * every link on the page. - */ interface LinkState { href: string | undefined externalLink: string | undefined @@ -54,10 +47,6 @@ function compareLinkState(a: LinkState, b: LinkState) { ) } -/** - * Resolves the absolute URL a link points at, or `undefined` when it is - * internal. Also the gate for dangerous protocols. - */ function resolveExternalLink( hrefOption: { href: string; external?: boolean } | undefined, to: unknown, @@ -73,8 +62,12 @@ function resolveExternalLink( } return hrefOption.href } - 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) // Block dangerous protocols like javascript:, blob:, data: @@ -89,7 +82,6 @@ function resolveExternalLink( return undefined } -/** Whether `next` is the location currently being viewed. */ function resolveIsActive( location: ParsedLocation, next: ParsedLocation, @@ -98,7 +90,9 @@ function resolveIsActive( isHydrated: boolean, isExternal: boolean, ): boolean { - if (isExternal) return false + if (isExternal) { + return false + } if (activeOptions?.exact) { const testExact = exactPathTest(location.pathname, next.pathname, basepath) if (!testExact) { @@ -509,14 +503,9 @@ export function useLinkProps< includeSearch: activeIncludeSearch, } = activeOptions ?? {} - // Everything the rendered output derives from the location is computed inside - // the selector, so `compareLinkState` can suppress the re-render when none of - // it changed. - // - // Deriving these *after* subscribing to the whole location meant every Link on - // the page re-rendered on every navigation: the comparator could only ask "is - // this a different URL?", never "does this link care?". For all but the few - // links involved in a navigation, `href` and the active state are unchanged. + // Derive inside the selector so `compareLinkState` can bail out. Deriving after + // the subscription instead re-renders every link on every navigation, because + // the comparator only sees the location, not whether this link's output moved. // eslint-disable-next-line react-hooks/rules-of-hooks const selectLinkState = React.useCallback( (location: ParsedLocation): LinkState => { @@ -560,9 +549,8 @@ export function useLinkProps< ), } }, - // `activeOptions` is spread into primitives above rather than listed whole: - // callers routinely pass an inline object literal, and depending on its - // identity would rebuild this selector on every render. + // Spread into primitives: `activeOptions` is routinely an inline object + // literal, so depending on it directly rebuilds the selector every render. [ activeExact, activeExplicitUndefined, @@ -624,9 +612,8 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const doPreload = React.useCallback(() => { - // No `_builtLocation`: the built location is no longer kept in render state, - // and `preloadRoute` builds it itself (`opts._builtLocation ?? buildLocation`). - // This matches `handleClick`, which has always let `router.navigate` build it. + // `preloadRoute` builds the location itself; it is no longer held in render + // state. Matches `handleClick`, which lets `router.navigate` build its own. router.preloadRoute({ ..._options } as any).catch((err) => { console.warn(err) console.warn(preloadWarning) From 2ec281e865f9d8bcbdc62d1768e0619021815db9 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Wed, 5 Aug 2026 22:59:15 +0100 Subject: [PATCH 03/14] refactor: publish link state as a tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/react-router/src/link.tsx | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index b60f2aa499a..315e70ff6bf 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -33,18 +33,14 @@ import type { ValidateLinkOptionsArray, } from './typePrimitives' -interface LinkState { - href: string | undefined - externalLink: string | undefined - isActive: boolean -} +type LinkState = [ + href: string | undefined, + externalLink: string | undefined, + isActive: boolean, +] function compareLinkState(a: LinkState, b: LinkState) { - return ( - a.href === b.href && - a.externalLink === b.externalLink && - a.isActive === b.isActive - ) + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] } function resolveExternalLink( @@ -531,10 +527,10 @@ export function useLinkProps< router.protocolAllowlist, ) - return { - href: hrefOption?.href, + return [ + hrefOption?.href, externalLink, - isActive: resolveIsActive( + resolveIsActive( location, next, { @@ -547,7 +543,7 @@ export function useLinkProps< isHydrated, externalLink !== undefined, ), - } + ] }, // Spread into primitives: `activeOptions` is routinely an inline object // literal, so depending on it directly rebuilds the selector every render. @@ -565,7 +561,7 @@ export function useLinkProps< ) // eslint-disable-next-line react-hooks/rules-of-hooks - const { href, externalLink, isActive } = useStore( + const [href, externalLink, isActive] = useStore( router.stores.location, selectLinkState, compareLinkState, From 8d04b0b387410cc5fe165e09b83e8f6cd2b1c37d Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Wed, 5 Aug 2026 23:04:17 +0100 Subject: [PATCH 04/14] test: assert the published link state, and drop a redundant cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/react-router/src/link.tsx | 2 +- packages/react-router/tests/link.test.tsx | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 315e70ff6bf..0f639591061 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -65,7 +65,7 @@ function resolveExternalLink( return undefined } try { - new URL(to as any) + new URL(to) // Block dangerous protocols like javascript:, blob:, data: if (isDangerousProtocol(to, protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 3a2e98659bd..b6a87a779cf 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -7618,16 +7618,29 @@ describe('link re-render bail-out', () => { await screen.findByTestId('unaffected') const before = { ...renderCounts } + expect(screen.getByTestId('becomesActive')).not.toHaveAttribute( + 'data-status', + ) fireEvent.click(await screen.findByTestId('go')) expect(await screen.findByText('Posts')).toBeInTheDocument() // `/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. + // bail out rather than publish an equal value. Asserting the published + // values too, so a selector that returned a constant would still fail. expect(renderCounts.unaffected).toBe(before.unaffected) + expect(screen.getByTestId('unaffected')).toHaveAttribute( + 'href', + '/elsewhere', + ) + expect(screen.getByTestId('unaffected')).not.toHaveAttribute('data-status') }) }) From 07081d4275075acce2afdc4ad46ade5e1ac8e829 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Wed, 5 Aug 2026 23:41:30 +0100 Subject: [PATCH 05/14] refactor: pass activeOptions through instead of destructuring 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). --- packages/react-router/src/link.tsx | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 0f639591061..2d365b235b5 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -492,13 +492,6 @@ export function useLinkProps< ], ) - const { - exact: activeExact, - explicitUndefined: activeExplicitUndefined, - includeHash: activeIncludeHash, - includeSearch: activeIncludeSearch, - } = activeOptions ?? {} - // Derive inside the selector so `compareLinkState` can bail out. Deriving after // the subscription instead re-renders every link on every navigation, because // the comparator only sees the location, not whether this link's output moved. @@ -533,25 +526,23 @@ export function useLinkProps< resolveIsActive( location, next, - { - exact: activeExact, - explicitUndefined: activeExplicitUndefined, - includeHash: activeIncludeHash, - includeSearch: activeIncludeSearch, - }, + activeOptions, router.basepath, isHydrated, externalLink !== undefined, ), ] }, - // Spread into primitives: `activeOptions` is routinely an inline object - // literal, so depending on it directly rebuilds the selector every render. + // Depend on the four fields rather than `activeOptions` itself: callers + // routinely pass an inline object literal, which would rebuild the selector + // every render. `resolveIsActive` reads only these four, so the disable is + // not hiding a live dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps [ - activeExact, - activeExplicitUndefined, - activeIncludeHash, - activeIncludeSearch, + activeOptions?.exact, + activeOptions?.explicitUndefined, + activeOptions?.includeHash, + activeOptions?.includeSearch, disabled, isHydrated, _options, From 409371b1dcd0649c60ecbdaaf34ea63b65fceb36 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Thu, 6 Aug 2026 00:20:21 +0100 Subject: [PATCH 06/14] perf: memoize the href derivation on the built href 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. --- packages/react-router/src/link.tsx | 101 +++++++++++++++++++---------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 2d365b235b5..59c93f72f2c 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -496,42 +496,71 @@ export function useLinkProps< // the subscription instead re-renders every link on every navigation, because // the comparator only sees the location, not whether this link's output moved. // eslint-disable-next-line react-hooks/rules-of-hooks - const selectLinkState = React.useCallback( - (location: ParsedLocation): LinkState => { - const next = router.buildLocation({ - _fromLocation: location, - ..._options, - } as any) - - // Use publicHref - it contains the correct href for display - // When a rewrite changes the origin, publicHref is the full URL - // Otherwise it's the origin-stripped path - // This avoids constructing URL objects in the hot path - const hrefOption = getHrefOption( - next.maskedLocation ? next.maskedLocation.publicHref : next.publicHref, - next.maskedLocation ? next.maskedLocation.external : next.external, - router.history, - disabled, - ) - - const externalLink = resolveExternalLink( - hrefOption, - to, - router.protocolAllowlist, - ) - - return [ - hrefOption?.href, - externalLink, - resolveIsActive( - location, - next, - activeOptions, - router.basepath, - isHydrated, - externalLink !== undefined, - ), - ] + const selectLinkState = React.useMemo( + () => { + // `getHrefOption` and `resolveExternalLink` depend only on the built href, + // which a navigation usually leaves untouched. Memoize them on it so they + // are skipped on the navigations that do not move this link, which is what + // the equivalent `useMemo` chain used to do via its primitive dependencies. + // The closure is rebuilt whenever a dependency below changes, so the cache + // cannot outlive the inputs it was derived from. + let cachedHref: string | undefined + let cachedExternal: boolean | undefined + let cachedHrefOption: ReturnType + let cachedExternalLink: string | undefined + let hasCache = false + + return (location: ParsedLocation): LinkState => { + const next = router.buildLocation({ + _fromLocation: location, + ..._options, + } as any) + + // Use publicHref - it contains the correct href for display + // When a rewrite changes the origin, publicHref is the full URL + // Otherwise it's the origin-stripped path + // This avoids constructing URL objects in the hot path + const publicHref = next.maskedLocation + ? next.maskedLocation.publicHref + : next.publicHref + const external = next.maskedLocation + ? next.maskedLocation.external + : next.external + + if ( + !hasCache || + publicHref !== cachedHref || + external !== cachedExternal + ) { + hasCache = true + cachedHref = publicHref + cachedExternal = external + cachedHrefOption = getHrefOption( + publicHref, + external, + router.history, + disabled, + ) + cachedExternalLink = resolveExternalLink( + cachedHrefOption, + to, + router.protocolAllowlist, + ) + } + + return [ + cachedHrefOption?.href, + cachedExternalLink, + resolveIsActive( + location, + next, + activeOptions, + router.basepath, + isHydrated, + cachedExternalLink !== undefined, + ), + ] + } }, // Depend on the four fields rather than `activeOptions` itself: callers // routinely pass an inline object literal, which would rebuild the selector From 08735d2e6e6b34b36947fe93a9487857ef5381d3 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Thu, 6 Aug 2026 00:39:59 +0100 Subject: [PATCH 07/14] perf: keep _options referentially stable while its contents are equal 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. --- packages/react-router/src/link.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 59c93f72f2c..0414901dd26 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -39,6 +39,16 @@ type LinkState = [ isActive: boolean, ] +// EXPERIMENT: keep a referentially stable value while the contents are equal, +// so callers passing inline object literals do not change `_options` identity. +function useValueStable(value: T): T { + const ref = React.useRef(value) + if (ref.current !== value && !deepEqual(ref.current, value)) { + ref.current = value + } + return ref.current +} + function compareLinkState(a: LinkState, b: LinkState) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] } @@ -475,6 +485,10 @@ export function useLinkProps< const isHydrated = useHydrated() // eslint-disable-next-line react-hooks/rules-of-hooks + // eslint-disable-next-line react-hooks/rules-of-hooks + const stableSearch = useValueStable(options.search) + // eslint-disable-next-line react-hooks/rules-of-hooks + const stableParams = useValueStable(options.params) const _options = React.useMemo( () => options, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -484,8 +498,8 @@ export function useLinkProps< options._fromLocation, options.hash, options.to, - options.search, - options.params, + stableSearch, + stableParams, options.state, options.mask, options.unsafeRelative, From 1f62425c6c0b857a8cc8c22a7fd57ccc591ad73f Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Thu, 6 Aug 2026 01:15:37 +0100 Subject: [PATCH 08/14] revert: drop the href memoization, it measured no benefit 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. --- packages/react-router/src/link.tsx | 103 +++++++++++------------------ 1 file changed, 37 insertions(+), 66 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 0414901dd26..21a9e8d2af1 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -484,11 +484,11 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const isHydrated = useHydrated() - // eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks const stableSearch = useValueStable(options.search) // eslint-disable-next-line react-hooks/rules-of-hooks const stableParams = useValueStable(options.params) + // eslint-disable-next-line react-hooks/rules-of-hooks const _options = React.useMemo( () => options, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -510,71 +510,42 @@ export function useLinkProps< // the subscription instead re-renders every link on every navigation, because // the comparator only sees the location, not whether this link's output moved. // eslint-disable-next-line react-hooks/rules-of-hooks - const selectLinkState = React.useMemo( - () => { - // `getHrefOption` and `resolveExternalLink` depend only on the built href, - // which a navigation usually leaves untouched. Memoize them on it so they - // are skipped on the navigations that do not move this link, which is what - // the equivalent `useMemo` chain used to do via its primitive dependencies. - // The closure is rebuilt whenever a dependency below changes, so the cache - // cannot outlive the inputs it was derived from. - let cachedHref: string | undefined - let cachedExternal: boolean | undefined - let cachedHrefOption: ReturnType - let cachedExternalLink: string | undefined - let hasCache = false - - return (location: ParsedLocation): LinkState => { - const next = router.buildLocation({ - _fromLocation: location, - ..._options, - } as any) - - // Use publicHref - it contains the correct href for display - // When a rewrite changes the origin, publicHref is the full URL - // Otherwise it's the origin-stripped path - // This avoids constructing URL objects in the hot path - const publicHref = next.maskedLocation - ? next.maskedLocation.publicHref - : next.publicHref - const external = next.maskedLocation - ? next.maskedLocation.external - : next.external - - if ( - !hasCache || - publicHref !== cachedHref || - external !== cachedExternal - ) { - hasCache = true - cachedHref = publicHref - cachedExternal = external - cachedHrefOption = getHrefOption( - publicHref, - external, - router.history, - disabled, - ) - cachedExternalLink = resolveExternalLink( - cachedHrefOption, - to, - router.protocolAllowlist, - ) - } - - return [ - cachedHrefOption?.href, - cachedExternalLink, - resolveIsActive( - location, - next, - activeOptions, - router.basepath, - isHydrated, - cachedExternalLink !== undefined, - ), - ] - } + const selectLinkState = React.useCallback( + (location: ParsedLocation): LinkState => { + const next = router.buildLocation({ + _fromLocation: location, + ..._options, + } as any) + + // Use publicHref - it contains the correct href for display + // When a rewrite changes the origin, publicHref is the full URL + // Otherwise it's the origin-stripped path + // This avoids constructing URL objects in the hot path + const hrefOption = getHrefOption( + next.maskedLocation ? next.maskedLocation.publicHref : next.publicHref, + next.maskedLocation ? next.maskedLocation.external : next.external, + router.history, + disabled, + ) + + const externalLink = resolveExternalLink( + hrefOption, + to, + router.protocolAllowlist, + ) + + return [ + hrefOption?.href, + externalLink, + resolveIsActive( + location, + next, + activeOptions, + router.basepath, + isHydrated, + externalLink !== undefined, + ), + ] }, // Depend on the four fields rather than `activeOptions` itself: callers // routinely pass an inline object literal, which would rebuild the selector From 48dc6878a55b2a6df4ac280a2b263cb7832ca505 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Thu, 6 Aug 2026 01:36:43 +0100 Subject: [PATCH 09/14] docs: explain why useValueStable exists Replaces a leftover scratch note. --- packages/react-router/src/link.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 21a9e8d2af1..9897bc18dd3 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -39,8 +39,10 @@ type LinkState = [ isActive: boolean, ] -// EXPERIMENT: keep a referentially stable value while the contents are equal, -// so callers passing inline object literals do not change `_options` identity. +// Keep a referentially stable value while the contents are equal. Links +// routinely pass inline `params` / `search` object literals, which would +// otherwise change `_options` identity on every parent render, rebuild the +// store selector, and discard its memoized selection. function useValueStable(value: T): T { const ref = React.useRef(value) if (ref.current !== value && !deepEqual(ref.current, value)) { From 390fe996da3f2cf219b39e154fd2627acf0b13c3 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Thu, 6 Aug 2026 13:01:27 +0100 Subject: [PATCH 10/14] perf: cut the bundle cost of the link state selector Stabilise `activeOptions` with the same helper used for `search` / `params`, so the selector depends on one value instead of four destructured fields. That also makes the dependency array honest, so the exhaustive-deps disable goes. Drop the reference-equality guard in useValueStable: deepEqual already short-circuits on `a === b`, so the guard only saved a function call that returns immediately. Measured with benchmarks/bundle-size against this branch's base, gzip delta across the eight React scenarios moves from +27/-7 to +11/-24, and raw bytes go uniformly negative (-37 to -45 on every scenario). --- packages/react-router/src/link.tsx | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 9897bc18dd3..860972095ec 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -45,7 +45,8 @@ type LinkState = [ // store selector, and discard its memoized selection. function useValueStable(value: T): T { const ref = React.useRef(value) - if (ref.current !== value && !deepEqual(ref.current, value)) { + // `deepEqual` short-circuits on reference equality, so this covers both cases. + if (!deepEqual(ref.current, value)) { ref.current = value } return ref.current @@ -491,6 +492,8 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const stableParams = useValueStable(options.params) // eslint-disable-next-line react-hooks/rules-of-hooks + const stableActiveOptions = useValueStable(activeOptions) + // eslint-disable-next-line react-hooks/rules-of-hooks const _options = React.useMemo( () => options, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -542,29 +545,14 @@ export function useLinkProps< resolveIsActive( location, next, - activeOptions, + stableActiveOptions, router.basepath, isHydrated, externalLink !== undefined, ), ] }, - // Depend on the four fields rather than `activeOptions` itself: callers - // routinely pass an inline object literal, which would rebuild the selector - // every render. `resolveIsActive` reads only these four, so the disable is - // not hiding a live dependency. - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - activeOptions?.exact, - activeOptions?.explicitUndefined, - activeOptions?.includeHash, - activeOptions?.includeSearch, - disabled, - isHydrated, - _options, - router, - to, - ], + [stableActiveOptions, disabled, isHydrated, _options, router, to], ) // eslint-disable-next-line react-hooks/rules-of-hooks From 0dbecc41d289c60314768b4e2027b3a0b9142652 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Thu, 6 Aug 2026 14:06:23 +0100 Subject: [PATCH 11/14] perf: pass _options to preloadRoute without the shallow clone The spread existed to add _builtLocation, which is gone, and nothing on the preload path mutates the options object: preloadRoute only reads opts._builtLocation, build() only reads dest fields, and the search middleware chain only reads dest.search. Search middlewares themselves receive { search, next }, never dest. Saves an object allocation per hover. Bytes are unchanged on gzip, -5 raw. --- packages/react-router/src/link.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 860972095ec..8e7a678cebd 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -604,8 +604,8 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const doPreload = React.useCallback(() => { // `preloadRoute` builds the location itself; it is no longer held in render - // state. Matches `handleClick`, which lets `router.navigate` build its own. - router.preloadRoute({ ..._options } as any).catch((err) => { + // state. It only reads the options, so `_options` can go through as-is. + router.preloadRoute(_options as any).catch((err) => { console.warn(err) console.warn(preloadWarning) }) From 8bd81fcd9b0a59d27162e7b9c33570bdbd25ad8e Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Thu, 6 Aug 2026 14:52:03 +0100 Subject: [PATCH 12/14] fix: do not collapse explicit-undefined params when stabilising link options useValueStable compared with deepEqual's default ignoreUndefined: true, which skips undefined-valued keys on both sides, so `{}` and `{ category: undefined }` compared equal. Those build different locations: an explicit undefined clears an inherited optional param while an empty object inherits it. A Link whose params changed from one to the other kept publishing the stale href, and click and preload used the stale options too. Compare with ignoreUndefined: false. Adds a regression test covering the /posts/tech -> /posts transition, which fails before this commit and passes after, and passes on the pre-PR baseline. --- packages/react-router/src/link.tsx | 6 ++- packages/react-router/tests/link.test.tsx | 59 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 8e7a678cebd..40a644e4a23 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -43,10 +43,14 @@ type LinkState = [ // routinely pass inline `params` / `search` object literals, which would // otherwise change `_options` identity on every parent render, rebuild the // store selector, and discard its memoized selection. +// +// `ignoreUndefined: false` is required: an explicit `undefined` clears an +// inherited param or search key, so `{}` and `{ category: undefined }` build +// different locations and must not be treated as equal here. function useValueStable(value: T): T { const ref = React.useRef(value) // `deepEqual` short-circuits on reference equality, so this covers both cases. - if (!deepEqual(ref.current, value)) { + if (!deepEqual(ref.current, value, { ignoreUndefined: false })) { ref.current = value } return ref.current diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index b6a87a779cf..b1c82b53686 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -7644,3 +7644,62 @@ describe('link re-render bail-out', () => { expect(screen.getByTestId('unaffected')).not.toHaveAttribute('data-status') }) }) + +describe('explicit-undefined params are not collapsed into an empty object', () => { + // `params: { category: undefined }` clears an inherited optional param while + // `params: {}` inherits it, so the two build different locations. The link + // options are stabilised by value, and that comparison must not treat them as + // equal or the link keeps publishing the stale href. + it('updates href when params goes from {} to { category: undefined }', async () => { + function CategoryLink({ + params, + }: { + params: Record + }) { + return ( + + link + + ) + } + + const rootRoute = createRootRoute({ component: () => }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/{-$category}', + component: function Posts() { + const [params, setParams] = React.useState< + Record + >({}) + return ( + <> + + + + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + history: createMemoryHistory({ initialEntries: ['/posts/tech'] }), + }) + + render() + + await waitFor(() => + expect(screen.getByTestId('lnk')).toHaveAttribute('href', '/posts/tech'), + ) + + fireEvent.click(screen.getByTestId('clear')) + + await waitFor(() => + expect(screen.getByTestId('lnk')).toHaveAttribute('href', '/posts'), + ) + }) +}) From 68af7c1f9bf867494e9d0b951e4ce34b30b5b548 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Fri, 7 Aug 2026 11:25:39 +0100 Subject: [PATCH 13/14] chore: empty commit to re-trigger CI From 382efe91b2b5cf6843f0423ec76fe8b6971f44c8 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Fri, 7 Aug 2026 16:34:42 +0100 Subject: [PATCH 14/14] chore: add changeset --- .changeset/olive-donkeys-shave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/olive-donkeys-shave.md diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md new file mode 100644 index 00000000000..158e99b7d38 --- /dev/null +++ b/.changeset/olive-donkeys-shave.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +bail out of `Link` re-renders when the resolved href and active state are unchanged