diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 9437454820..1a8eda4eb5 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,7 +94,290 @@ 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 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' && + !safeInternal && + // 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) + + // 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) { + if (isDangerousProtocol(hrefOption.href)) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + `Blocked Link with dangerous protocol: ${hrefOption.href}`, + ) + } + return undefined + } + return hrefOption.href + } + + if (safeInternal) return undefined + + // 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 to + } catch {} + } + + return undefined + })() + + const isActive = (() => { + if (externalLink) return false + + const currentLocation = router.state.location + + const exact = activeOptions?.exact ?? false + + if (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 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 + } + } + } + } + + // Hash is not available on the server + if (activeOptions?.includeHash) { + return false + } + + 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 = (() => { + 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 = (() => { + const baseClassName = className + const activeClassName = resolvedActiveProps.className + const inactiveClassName = resolvedInactiveProps.className + + if (!baseClassName && !activeClassName && !inactiveClassName) { + return '' + } + + let out = '' + + if (baseClassName) { + out = baseClassName + } + + if (activeClassName) { + out = out ? `${out} ${activeClassName}` : activeClassName + } + + if (inactiveClassName) { + out = out ? `${out} ${inactiveClassName}` : inactiveClassName + } + + return out + })() + + 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), + } + } + + // ========================================================================== + // 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, @@ -101,6 +385,7 @@ export function useLinkProps< const from = options.from + // eslint-disable-next-line react-hooks/rules-of-hooks const _options = React.useMemo( () => { return { ...options, from } @@ -121,6 +406,7 @@ export function useLinkProps< ], ) + // eslint-disable-next-line react-hooks/rules-of-hooks const next = React.useMemo( () => router.buildLocation({ ..._options } as any), [router, _options], @@ -136,20 +422,19 @@ 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]) + // eslint-disable-next-line react-hooks/rules-of-hooks + const hrefOption = React.useMemo( + () => + getHrefOption( + hrefOptionPublicHref, + hrefOptionExternal, + 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 @@ -163,15 +448,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}`) } @@ -182,13 +465,7 @@ export function useLinkProps< return undefined }, [to, hrefOption]) - 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 isActive = useRouterState({ select: (s) => { if (externalLink) return false @@ -238,6 +515,46 @@ 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, + } + + // 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 +562,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 +572,7 @@ export function useLinkProps< [doPreload], ) + // eslint-disable-next-line react-hooks/rules-of-hooks useIntersectionObserver( innerRef, preloadViewportIoCallback, @@ -261,6 +580,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 +649,6 @@ export function useLinkProps< } } - // The click handler const handleFocus = (_: React.MouseEvent) => { if (disabled) return if (preload) { @@ -367,33 +686,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 +703,7 @@ export function useLinkProps< ...(resolvedClassName && { className: resolvedClassName }), ...(disabled && STATIC_DISABLED_PROPS), ...(isActive && STATIC_ACTIVE_PROPS), - ...(isTransitioning && STATIC_TRANSITIONING_PROPS), + ...(isHydrated && isTransitioning && STATIC_TRANSITIONING_PROPS), } } @@ -437,6 +729,30 @@ 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, + } +} + +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