From f73cffa802c9ef54a68d7a91b28b60ae4a7c7d87 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 24 Jan 2026 11:02:05 +0100 Subject: [PATCH 1/8] ``` [1] 52k requests in 30.01s, 652 MB read [1] [1] === SSR Benchmark Results === [1] Total requests: 48512 [1] Requests/sec: 1617.07 [1] Latency (avg): 3.85ms [1] Latency (p99): 12ms [1] Throughput: 20.72 MB/s ``` --- packages/react-router/src/link.tsx | 278 +++++++++++++++++++++++++---- 1 file changed, 239 insertions(+), 39 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 9437454820..021612b18b 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -5,6 +5,7 @@ import { exactPathTest, functionalUpdate, isDangerousProtocol, + isServer, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' @@ -50,10 +51,10 @@ export function useLinkProps< forwardedRef?: React.ForwardedRef, ): React.ComponentPropsWithRef<'a'> { const router = useRouter() - const [isTransitioning, setIsTransitioning] = React.useState(false) - const hasRenderFetched = React.useRef(false) const innerRef = useForwardedRef(forwardedRef) - const isHydrated = useHydrated() + + // Determine if we're on the server - used for tree-shaking client-only code + const _isServer = isServer ?? router.isServer const { // custom props @@ -93,6 +94,184 @@ export function useLinkProps< ...propsSafeToSpread } = options + // ========================================================================== + // SERVER EARLY RETURN + // On the server, we return static props without any event handlers, + // effects, or client-side interactivity. + // + // For SSR parity (to avoid hydration errors), we still compute the link's + // active status on the server, but we avoid creating any router-state + // subscriptions by reading from `router.state` directly. + // + // Note: `location.hash` is not available on the server. + // ========================================================================== + if (_isServer) { + const next = router.buildLocation({ ...options, from: options.from } as any) + + const hrefOption = (() => { + if (disabled) { + return undefined + } + let href = next.maskedLocation + ? next.maskedLocation.url.href + : next.url.href + + let external = false + if (router.origin) { + if (href.startsWith(router.origin)) { + href = + router.history.createHref(href.replace(router.origin, '')) || '/' + } else { + external = true + } + } + return { href, external } + })() + + const externalLink = (() => { + if (hrefOption?.external) { + if (isDangerousProtocol(hrefOption.href)) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + `Blocked Link with dangerous protocol: ${hrefOption.href}`, + ) + } + return undefined + } + return hrefOption.href + } + const isSafeInternal = + typeof to === 'string' && + to.charCodeAt(0) === 47 && // '/' + to.charCodeAt(1) !== 47 // but not '//' + if (isSafeInternal) return undefined + try { + new URL(to as any) + if (isDangerousProtocol(to as string)) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${to}`) + } + return undefined + } + return to + } catch {} + return undefined + })() + + const isActive = (() => { + if (externalLink) return false + + const currentLocation = router.state.location + + 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 + } + } + + if (activeOptions?.includeSearch ?? true) { + const searchTest = deepEqual(currentLocation.search, next.search, { + partial: !activeOptions?.exact, + ignoreUndefined: !activeOptions?.explicitUndefined, + }) + if (!searchTest) { + return false + } + } + + // Hash is not available on the server + return true + })() + + if (externalLink) { + return { + ...propsSafeToSpread, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], + href: externalLink, + ...(children && { children }), + ...(target && { target }), + ...(disabled && { disabled }), + ...(style && { style }), + ...(className && { className }), + } + } + + const resolvedActiveProps: React.HTMLAttributes = + isActive + ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT) + : STATIC_EMPTY_OBJECT + + const resolvedInactiveProps: React.HTMLAttributes = + isActive + ? STATIC_EMPTY_OBJECT + : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT) + + const resolvedStyle = (style || + resolvedActiveProps.style || + resolvedInactiveProps.style) && { + ...style, + ...resolvedActiveProps.style, + ...resolvedInactiveProps.style, + } + + const resolvedClassName = (() => { + if ( + !className && + !resolvedActiveProps.className && + !resolvedInactiveProps.className + ) { + return '' + } + + return [ + className, + resolvedActiveProps.className, + resolvedInactiveProps.className, + ] + .filter(Boolean) + .join(' ') + })() + + return { + ...propsSafeToSpread, + ...resolvedActiveProps, + ...resolvedInactiveProps, + href: hrefOption?.href, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], + disabled: !!disabled, + target, + ...(resolvedStyle && { style: resolvedStyle }), + ...(resolvedClassName && { className: resolvedClassName }), + ...(disabled && STATIC_DISABLED_PROPS), + ...(isActive && STATIC_ACTIVE_PROPS), + } + } + + const isHydrated = useHydrated() + // subscribe to search params to re-build location if it changes const currentSearch = useRouterState({ select: (s) => s.location.search, @@ -182,13 +361,6 @@ export function useLinkProps< return undefined }, [to, hrefOption]) - const preload = - options.reloadDocument || externalLink - ? false - : (userPreload ?? router.options.defaultPreload) - const preloadDelay = - userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0 - const isActive = useRouterState({ select: (s) => { if (externalLink) return false @@ -238,6 +410,59 @@ export function useLinkProps< }, }) + // Get the active props + const resolvedActiveProps: React.HTMLAttributes = isActive + ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT) + : STATIC_EMPTY_OBJECT + + // Get the inactive props + const resolvedInactiveProps: React.HTMLAttributes = + isActive + ? STATIC_EMPTY_OBJECT + : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT) + + const resolvedClassName = [ + className, + resolvedActiveProps.className, + resolvedInactiveProps.className, + ] + .filter(Boolean) + .join(' ') + + const resolvedStyle = (style || + resolvedActiveProps.style || + resolvedInactiveProps.style) && { + ...style, + ...resolvedActiveProps.style, + ...resolvedInactiveProps.style, + } + + // ========================================================================== + // CLIENT-ONLY CODE + // Everything below this point only runs on the client. The `isServer` check + // above is a compile-time constant that bundlers use for dead code elimination, + // so this entire section is removed from server bundles. + // + // We disable the rules-of-hooks lint rule because these hooks appear after + // an early return. This is safe because: + // 1. `isServer` is a compile-time constant from conditional exports + // 2. In server bundles, this code is completely eliminated by the bundler + // 3. In client bundles, `isServer` is `false`, so the early return never executes + // ========================================================================== + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [isTransitioning, setIsTransitioning] = React.useState(false) + // eslint-disable-next-line react-hooks/rules-of-hooks + const hasRenderFetched = React.useRef(false) + + const preload = + options.reloadDocument || externalLink + ? false + : (userPreload ?? router.options.defaultPreload) + const preloadDelay = + userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0 + + // eslint-disable-next-line react-hooks/rules-of-hooks const doPreload = React.useCallback(() => { router.preloadRoute({ ..._options } as any).catch((err) => { console.warn(err) @@ -245,6 +470,7 @@ export function useLinkProps< }) }, [router, _options]) + // eslint-disable-next-line react-hooks/rules-of-hooks const preloadViewportIoCallback = React.useCallback( (entry: IntersectionObserverEntry | undefined) => { if (entry?.isIntersecting) { @@ -254,6 +480,7 @@ export function useLinkProps< [doPreload], ) + // eslint-disable-next-line react-hooks/rules-of-hooks useIntersectionObserver( innerRef, preloadViewportIoCallback, @@ -261,6 +488,7 @@ export function useLinkProps< { disabled: !!disabled || !(preload === 'viewport') }, ) + // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { if (hasRenderFetched.current) { return @@ -329,7 +557,6 @@ export function useLinkProps< } } - // The click handler const handleFocus = (_: React.MouseEvent) => { if (disabled) return if (preload) { @@ -367,33 +594,6 @@ export function useLinkProps< } } - // Get the active props - const resolvedActiveProps: React.HTMLAttributes = isActive - ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT) - : STATIC_EMPTY_OBJECT - - // Get the inactive props - const resolvedInactiveProps: React.HTMLAttributes = - isActive - ? STATIC_EMPTY_OBJECT - : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT) - - const resolvedClassName = [ - className, - resolvedActiveProps.className, - resolvedInactiveProps.className, - ] - .filter(Boolean) - .join(' ') - - const resolvedStyle = (style || - resolvedActiveProps.style || - resolvedInactiveProps.style) && { - ...style, - ...resolvedActiveProps.style, - ...resolvedInactiveProps.style, - } - return { ...propsSafeToSpread, ...resolvedActiveProps, @@ -411,7 +611,7 @@ export function useLinkProps< ...(resolvedClassName && { className: resolvedClassName }), ...(disabled && STATIC_DISABLED_PROPS), ...(isActive && STATIC_ACTIVE_PROPS), - ...(isTransitioning && STATIC_TRANSITIONING_PROPS), + ...(isHydrated && isTransitioning && STATIC_TRANSITIONING_PROPS), } } From 96fac054c5db2cafa3c2bb66f2bbeb4387e444d6 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 24 Jan 2026 11:02:17 +0100 Subject: [PATCH 2/8] ``` [1] 53k requests in 30.01s, 672 MB read [1] [1] === SSR Benchmark Results === [1] Total requests: 50006 [1] Requests/sec: 1666.87 [1] Latency (avg): 3.67ms [1] Latency (p99): 11ms [1] Throughput: 21.36 MB/s ``` --- packages/react-router/src/link.tsx | 175 ++++++++++++++++++++++------- 1 file changed, 135 insertions(+), 40 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 021612b18b..8e7c7fc14c 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -106,6 +106,55 @@ export function useLinkProps< // Note: `location.hash` is not available on the server. // ========================================================================== if (_isServer) { + const isSafeInternal = + typeof to === 'string' && + (to.charCodeAt(0) === 47 + ? // '/' + to.charCodeAt(1) !== 47 // but not '//' + : // '.', '..', './', '../' + to.charCodeAt(0) === 46) + + // If `to` is obviously an absolute URL, treat as external and avoid + // computing the internal location via `buildLocation`. + if ( + typeof to === 'string' && + !isSafeInternal && + // Quick checks to avoid `new URL` in common internal-like cases + to.indexOf(':') > -1 + ) { + try { + new URL(to) + if (isDangerousProtocol(to)) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${to}`) + } + return { + ...propsSafeToSpread, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], + href: undefined, + ...(children && { children }), + ...(target && { target }), + ...(disabled && { disabled }), + ...(style && { style }), + ...(className && { className }), + } + } + + return { + ...propsSafeToSpread, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], + href: to, + ...(children && { children }), + ...(target && { target }), + ...(disabled && { disabled }), + ...(style && { style }), + ...(className && { className }), + } + } catch { + // Not an absolute URL + } + } + const next = router.buildLocation({ ...options, from: options.from } as any) const hrefOption = (() => { @@ -140,21 +189,23 @@ export function useLinkProps< } return hrefOption.href } - const isSafeInternal = - typeof to === 'string' && - to.charCodeAt(0) === 47 && // '/' - to.charCodeAt(1) !== 47 // but not '//' + if (isSafeInternal) return undefined - try { - new URL(to as any) - if (isDangerousProtocol(to as string)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${to}`) + + // Only attempt URL parsing when it looks like an absolute URL. + if (typeof to === 'string' && to.indexOf(':') > -1) { + try { + new URL(to) + if (isDangerousProtocol(to)) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${to}`) + } + return undefined } - return undefined - } - return to - } catch {} + return to + } catch {} + } + return undefined })() @@ -163,7 +214,9 @@ export function useLinkProps< const currentLocation = router.state.location - if (activeOptions?.exact) { + const exact = activeOptions?.exact ?? false + + if (exact) { const testExact = exactPathTest( currentLocation.pathname, next.pathname, @@ -192,13 +245,27 @@ export function useLinkProps< } } - if (activeOptions?.includeSearch ?? true) { - const searchTest = deepEqual(currentLocation.search, next.search, { - partial: !activeOptions?.exact, - ignoreUndefined: !activeOptions?.explicitUndefined, - }) - if (!searchTest) { - return false + const includeSearch = activeOptions?.includeSearch ?? true + if (includeSearch) { + if (currentLocation.search !== next.search) { + const currentSearchEmpty = + !currentLocation.search || + (typeof currentLocation.search === 'object' && + Object.keys(currentLocation.search).length === 0) + const nextSearchEmpty = + !next.search || + (typeof next.search === 'object' && + Object.keys(next.search).length === 0) + + if (!(currentSearchEmpty && nextSearchEmpty)) { + const searchTest = deepEqual(currentLocation.search, next.search, { + partial: !exact, + ignoreUndefined: !activeOptions?.explicitUndefined, + }) + if (!searchTest) { + return false + } + } } } @@ -229,30 +296,58 @@ export function useLinkProps< ? STATIC_EMPTY_OBJECT : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT) - const resolvedStyle = (style || - resolvedActiveProps.style || - resolvedInactiveProps.style) && { - ...style, - ...resolvedActiveProps.style, - ...resolvedInactiveProps.style, - } + const resolvedStyle = (() => { + const baseStyle = style + const activeStyle = resolvedActiveProps.style + const inactiveStyle = resolvedInactiveProps.style + + if (!baseStyle && !activeStyle && !inactiveStyle) { + return undefined + } + + if (baseStyle && !activeStyle && !inactiveStyle) { + return baseStyle + } + + if (!baseStyle && activeStyle && !inactiveStyle) { + return activeStyle + } + + if (!baseStyle && !activeStyle && inactiveStyle) { + return inactiveStyle + } + + return { + ...baseStyle, + ...activeStyle, + ...inactiveStyle, + } + })() const resolvedClassName = (() => { - if ( - !className && - !resolvedActiveProps.className && - !resolvedInactiveProps.className - ) { + const baseClassName = className + const activeClassName = resolvedActiveProps.className + const inactiveClassName = resolvedInactiveProps.className + + if (!baseClassName && !activeClassName && !inactiveClassName) { return '' } - return [ - className, - resolvedActiveProps.className, - resolvedInactiveProps.className, - ] - .filter(Boolean) - .join(' ') + let out = '' + + if (baseClassName) { + out = baseClassName + } + + if (activeClassName) { + out = out ? `${out} ${activeClassName}` : activeClassName + } + + if (inactiveClassName) { + out = out ? `${out} ${inactiveClassName}` : inactiveClassName + } + + return out })() return { From f0fdc0f1f2b4a629026cf78de0bfa6539eb1fdfb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 24 Jan 2026 11:53:27 +0100 Subject: [PATCH 3/8] fix merge conflicts --- packages/react-router/src/link.tsx | 75 +++++++++++++++++------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 8e7c7fc14c..2d4695479f 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -157,25 +157,22 @@ export function useLinkProps< const next = router.buildLocation({ ...options, from: options.from } as any) - const hrefOption = (() => { - if (disabled) { - return undefined - } - let href = next.maskedLocation - ? next.maskedLocation.url.href - : next.url.href - - let external = false - if (router.origin) { - if (href.startsWith(router.origin)) { - href = - router.history.createHref(href.replace(router.origin, '')) || '/' - } else { - external = true - } - } - return { href, external } - })() + // 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 + const hrefOption = getHrefOption( + hrefOptionPublicHref, + hrefOptionExternal, + router.history, + disabled, + ) const externalLink = (() => { if (hrefOption?.external) { @@ -410,19 +407,16 @@ export function useLinkProps< const hrefOptionExternal = next.maskedLocation ? next.maskedLocation.external : next.external - const hrefOption = React.useMemo(() => { - if (disabled) return undefined - - // Full URL means rewrite changed the origin - treat as external-like - if (hrefOptionExternal) { - return { href: hrefOptionPublicHref, external: true } - } - - return { - href: router.history.createHref(hrefOptionPublicHref) || '/', - external: false, - } - }, [disabled, hrefOptionExternal, hrefOptionPublicHref, router.history]) + const hrefOption = React.useMemo( + () => + getHrefOption( + hrefOptionPublicHref, + hrefOptionExternal, + router.history, + disabled, + ), + [disabled, hrefOptionExternal, hrefOptionPublicHref, router.history], + ) const externalLink = React.useMemo(() => { if (hrefOption?.external) { @@ -732,6 +726,23 @@ const composeHandlers = } } +function getHrefOption( + publicHref: string, + external: boolean, + history: AnyRouter['history'], + disabled: boolean | undefined, +) { + if (disabled) return undefined + // Full URL means rewrite changed the origin - treat as external-like + if (external) { + return { href: publicHref, external: true } + } + return { + href: history.createHref(publicHref) || '/', + external: false, + } +} + type UseLinkReactProps = TComp extends keyof React.JSX.IntrinsicElements ? React.JSX.IntrinsicElements[TComp] : TComp extends React.ComponentType From 8bd175dc24f04a60da1c2f553d1defbfb6eb0ef1 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 24 Jan 2026 11:54:38 +0100 Subject: [PATCH 4/8] rules of hooks --- packages/react-router/src/link.tsx | 33 ++++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 2d4695479f..3976d8eb11 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -362,9 +362,24 @@ export function useLinkProps< } } + // ========================================================================== + // CLIENT-ONLY CODE + // Everything below this point only runs on the client. The `isServer` check + // above is a compile-time constant that bundlers use for dead code elimination, + // so this entire section is removed from server bundles. + // + // We disable the rules-of-hooks lint rule because these hooks appear after + // an early return. This is safe because: + // 1. `isServer` is a compile-time constant from conditional exports + // 2. In server bundles, this code is completely eliminated by the bundler + // 3. In client bundles, `isServer` is `false`, so the early return never executes + // ========================================================================== + + // eslint-disable-next-line react-hooks/rules-of-hooks const isHydrated = useHydrated() // subscribe to search params to re-build location if it changes + // eslint-disable-next-line react-hooks/rules-of-hooks const currentSearch = useRouterState({ select: (s) => s.location.search, structuralSharing: true as any, @@ -372,6 +387,7 @@ export function useLinkProps< const from = options.from + // eslint-disable-next-line react-hooks/rules-of-hooks const _options = React.useMemo( () => { return { ...options, from } @@ -392,6 +408,7 @@ export function useLinkProps< ], ) + // eslint-disable-next-line react-hooks/rules-of-hooks const next = React.useMemo( () => router.buildLocation({ ..._options } as any), [router, _options], @@ -407,6 +424,7 @@ export function useLinkProps< const hrefOptionExternal = next.maskedLocation ? next.maskedLocation.external : next.external + // eslint-disable-next-line react-hooks/rules-of-hooks const hrefOption = React.useMemo( () => getHrefOption( @@ -418,6 +436,7 @@ export function useLinkProps< [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 @@ -450,6 +469,7 @@ export function useLinkProps< return undefined }, [to, hrefOption]) + // eslint-disable-next-line react-hooks/rules-of-hooks const isActive = useRouterState({ select: (s) => { if (externalLink) return false @@ -526,19 +546,6 @@ export function useLinkProps< ...resolvedInactiveProps.style, } - // ========================================================================== - // CLIENT-ONLY CODE - // Everything below this point only runs on the client. The `isServer` check - // above is a compile-time constant that bundlers use for dead code elimination, - // so this entire section is removed from server bundles. - // - // We disable the rules-of-hooks lint rule because these hooks appear after - // an early return. This is safe because: - // 1. `isServer` is a compile-time constant from conditional exports - // 2. In server bundles, this code is completely eliminated by the bundler - // 3. In client bundles, `isServer` is `false`, so the early return never executes - // ========================================================================== - // eslint-disable-next-line react-hooks/rules-of-hooks const [isTransitioning, setIsTransitioning] = React.useState(false) // eslint-disable-next-line react-hooks/rules-of-hooks From 975a67332a9383928b3ee361efaec7acf8072802 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 10:55:57 +0000 Subject: [PATCH 5/8] ci: apply automated fixes --- packages/react-router/src/link.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 3976d8eb11..3b257ad7c0 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -424,7 +424,7 @@ export function useLinkProps< const hrefOptionExternal = next.maskedLocation ? next.maskedLocation.external : next.external - // eslint-disable-next-line react-hooks/rules-of-hooks + // eslint-disable-next-line react-hooks/rules-of-hooks const hrefOption = React.useMemo( () => getHrefOption( From e92cc4994dfa096797bfd7ea3e6613f44f3ef792 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 24 Jan 2026 12:12:46 +0100 Subject: [PATCH 6/8] unify "safe internal" behavior between server and client --- packages/react-router/src/link.tsx | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 3b257ad7c0..f3d90fb3c3 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -106,19 +106,13 @@ export function useLinkProps< // Note: `location.hash` is not available on the server. // ========================================================================== if (_isServer) { - const isSafeInternal = - typeof to === 'string' && - (to.charCodeAt(0) === 47 - ? // '/' - to.charCodeAt(1) !== 47 // but not '//' - : // '.', '..', './', '../' - to.charCodeAt(0) === 46) + const safeInternal = isSafeInternal(to) // If `to` is obviously an absolute URL, treat as external and avoid // computing the internal location via `buildLocation`. if ( typeof to === 'string' && - !isSafeInternal && + !safeInternal && // Quick checks to avoid `new URL` in common internal-like cases to.indexOf(':') > -1 ) { @@ -187,7 +181,7 @@ export function useLinkProps< return hrefOption.href } - if (isSafeInternal) return undefined + if (safeInternal) return undefined // Only attempt URL parsing when it looks like an absolute URL. if (typeof to === 'string' && to.indexOf(':') > -1) { @@ -450,15 +444,13 @@ export function useLinkProps< } return hrefOption.href } - const isSafeInternal = - typeof to === 'string' && - to.charCodeAt(0) === 47 && // '/' - to.charCodeAt(1) !== 47 // but not '//' - if (isSafeInternal) return undefined + 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:, data:, vbscript: - if (isDangerousProtocol(to as string)) { + if (isDangerousProtocol(to)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } @@ -750,6 +742,13 @@ function getHrefOption( } } +function isSafeInternal(to: unknown) { + if (typeof to !== 'string') return false + const zero = to.charCodeAt(0) + if (zero === 47) return to.charCodeAt(1) !== 47 // '/' but not '//' + return zero === 46 // '.', '..', './', '../' +} + type UseLinkReactProps = TComp extends keyof React.JSX.IntrinsicElements ? React.JSX.IntrinsicElements[TComp] : TComp extends React.ComponentType From 757645e095e366d8f7723598524b15aa38d30167 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 24 Jan 2026 12:18:41 +0100 Subject: [PATCH 7/8] fix possible hydration mismatch --- 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 f3d90fb3c3..d6ed9bbc16 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -504,8 +504,8 @@ export function useLinkProps< } } - if (activeOptions?.includeHash) { - return isHydrated && s.location.hash === next.hash + if (isHydrated && activeOptions?.includeHash) { + return s.location.hash === next.hash } return true }, From fe11b4571a8e2dd7c251b8f9b14ce0728deef1ca Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 24 Jan 2026 12:22:05 +0100 Subject: [PATCH 8/8] better fix for active hash hydration mismatch --- packages/react-router/src/link.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index d6ed9bbc16..1a8eda4eb5 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -261,6 +261,10 @@ export function useLinkProps< } // Hash is not available on the server + if (activeOptions?.includeHash) { + return false + } + return true })() @@ -504,8 +508,8 @@ export function useLinkProps< } } - if (isHydrated && activeOptions?.includeHash) { - return s.location.hash === next.hash + if (activeOptions?.includeHash) { + return isHydrated && s.location.hash === next.hash } return true },