Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 165 additions & 125 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -31,6 +33,110 @@ import type {
ValidateLinkOptionsArray,
} from './typePrimitives'

type LinkState = [
href: string | undefined,
externalLink: string | undefined,
isActive: boolean,
]

// 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<T>(value: T): T {
const ref = React.useRef(value)
// `deepEqual` short-circuits on reference equality, so this covers both cases.
if (!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]
}

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)
// 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
}

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.
*
Expand Down Expand Up @@ -381,6 +487,12 @@ export function useLinkProps<
// eslint-disable-next-line react-hooks/rules-of-hooks
const isHydrated = useHydrated()

// 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 stableActiveOptions = useValueStable(activeOptions)
// eslint-disable-next-line react-hooks/rules-of-hooks
const _options = React.useMemo(
() => options,
Expand All @@ -391,136 +503,64 @@ export function useLinkProps<
options._fromLocation,
options.hash,
options.to,
options.search,
options.params,
stableSearch,
stableParams,
options.state,
options.mask,
options.unsafeRelative,
],
)

// 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 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 [
hrefOption?.href,
externalLink,
resolveIsActive(
location,
next,
stableActiveOptions,
router.basepath,
isHydrated,
externalLink !== undefined,
),
]
},
[stableActiveOptions, 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<HTMLAnchorElement> = isActive
Expand Down Expand Up @@ -563,13 +603,13 @@ 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])
// `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) => {

@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
Contributor 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
Contributor 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
Contributor 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
matclayton marked this conversation as resolved.
Outdated
console.warn(err)
console.warn(preloadWarning)
})
}, [router, _options])

// eslint-disable-next-line react-hooks/rules-of-hooks
const preloadViewportIoCallback = React.useCallback(
Expand Down Expand Up @@ -699,7 +739,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]),
Expand Down
Loading
Loading