From ef6726a2ab1d0bf04e69e635a5abe5a626a807ce Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:33:01 +0200 Subject: [PATCH] refactor(react-router): one key set and one assembly path for Link props The client and the server each kept their own copy of the 35 option names: the client as an object-rest destructure, the server as a Set. The two paths then assembled the result differently, with an eleven-spread external return and a fourteen-entry literal on the client and a fill-in-place sequence on the server. Both now share two helpers. `collectElementProps` copies every option the router does not consume, using one `ROUTER_OPTION_KEYS` set plus the two host rules (`Link` hosts never render `type`, an anchor has no `disabled`). `applyLinkState` finishes a router-controlled link: the selected state props, then the routing attributes and the merged class and style. It is the one place that defines precedence and absorbs `resolveStateProps`. The client adds its composed handlers between the two; the server adds nothing. External links return the collected props with `ref` and `href`. Element props therefore pass through as given instead of being stripped and re-added: external links forward them verbatim, falsy values included, and `useLinkProps` returns `children` for router-controlled links as it already did for external ones. Class and style are assigned only when the link or its state props provide one, so links without them carry no `undefined` keys and empty strings are still dropped. Measurements (macOS arm64, Node 24.8.0, local, against the previous commit): - react-router.minimal gzip 86019 -> 85984 (-35); -28 against the stack base, which this branch previously exceeded by 7. - Link client paired runner (3 repeats): shared-params CPU -13.8%, unique-params -11.4%, middleware -8.6%, encoding -13.2%, active -16.9%, all "faster". A 35-key object rest costs about 440 ns per call, the key set copy about 50 ns. - Link SSR paired runner (6 repeats): +0.4% to +1.5%, inside the runner's +/-4% noise floor measured head-against-head. SSR bundle 189850 -> 189523. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/brisk-links-serve.md | 2 + packages/react-router/src/link.tsx | 298 +++++++++++------------------ 2 files changed, 111 insertions(+), 189 deletions(-) diff --git a/.changeset/brisk-links-serve.md b/.changeset/brisk-links-serve.md index 504fd9414a..7b24280f24 100644 --- a/.changeset/brisk-links-serve.md +++ b/.changeset/brisk-links-serve.md @@ -6,3 +6,5 @@ Keep the Link location cache out of server bundles: `buildLocation` only creates, reads and writes it when `isServer` is false. Render React Links on the server without the extra prop copies and the forwarded-ref hook. Link SSR rendering is 20-40% faster in the Link benchmarks and the React Start SSR request loop about 7% faster. React `activeProps` and `inactiveProps` now follow one precedence rule on every link, including links whose destination is blocked for using a disallowed scheme: state props override element props, `ref` and event handlers, while `href`, `disabled` and `target` stay controlled by the router. Previously a blocked link ignored a `ref` or handler from its inactive props. + +React `Link` and `useLinkProps` split router options from element props with one key set on the client and the server. Element props pass through as given: external links forward them verbatim, falsy values included, and `useLinkProps` now returns `children` for router-controlled links as it already did for external ones. diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index b0006cb009..35d602ad83 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -194,50 +194,26 @@ function useLinkPropsFor< const innerRef = useForwardedRef(forwardedRef) const { - // custom props - activeProps, - inactiveProps, activeOptions, to: toOption, preload: userPreload, preloadDelay: userPreloadDelay, - preloadIntentProximity: _preloadIntentProximity, hashScrollIntoView, replace, startTransition, resetScroll, viewTransition, - // element props - children, - target, + ignoreBlocker, disabled, - style, - className, + target, onClick, onBlur, onFocus, onMouseEnter, onMouseLeave, onTouchStart, - ignoreBlocker, - // prevent these from being returned - params: _params, - search: _search, - hash: _hash, - state: _state, - mask: _mask, - reloadDocument: _reloadDocument, - unsafeRelative: _unsafeRelative, - from: _from, - _fromLocation, - _asChild: _asChildOption, - type, - ...propsSafeToSpread - } = options as typeof options & { _asChild?: unknown } + } = options const to = toOption as string | undefined - if (host === undefined && type !== undefined) { - ;(propsSafeToSpread as Record).type = type - } // eslint-disable-next-line react-hooks/rules-of-hooks const isHydrated = useHydrated() @@ -392,28 +368,14 @@ function useLinkPropsFor< } }, [doPreload, preload]) + const props = collectElementProps(options, host) + props.ref = innerRef + // External links get no router behavior: element props pass through as given. if (externalLink) { - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - href: externalLink, - ...(children && { children }), - ...(target && { target }), - ...(disabled && host !== 'a' && { disabled }), - ...(style && { style }), - ...(className && { className }), - ...(onClick && { onClick }), - ...(onBlur && { onBlur }), - ...(onFocus && { onFocus }), - ...(onMouseEnter && { onMouseEnter }), - ...(onMouseLeave && { onMouseLeave }), - ...(onTouchStart && { onTouchStart }), - } + props.href = externalLink + return props } - const [resolvedStateProps, resolvedClassName, resolvedStyle] = - resolveStateProps(isActive, activeProps, inactiveProps, className, style) - // The click handler const handleClick = (e: React.MouseEvent) => { // Check actual element's target attribute as fallback @@ -456,36 +418,30 @@ function useLinkPropsFor< } } - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - onClick: composeHandlers(onClick, handleClick), - onBlur: composeHandlers(onBlur, handleLeave), - onFocus: composeHandlers(onFocus, enqueuePreload), - onMouseEnter: composeHandlers(onMouseEnter, enqueuePreload), - onMouseLeave: composeHandlers(onMouseLeave, handleLeave), - onTouchStart: composeHandlers(onTouchStart, handleTouchStart), - // State props override element props, `ref` and handlers, but never the - // routing attributes below. - ...resolvedStateProps, - href, - ...(host !== 'a' && { disabled: !!linkDisabled }), - target, - ...(resolvedStyle && { style: resolvedStyle }), - ...(resolvedClassName && { className: resolvedClassName }), - ...(linkDisabled && STATIC_DISABLED_PROPS), - ...(isActive && STATIC_ACTIVE_PROPS), - } + props.onClick = composeHandlers(onClick, handleClick) + props.onBlur = composeHandlers(onBlur, handleLeave) + props.onFocus = composeHandlers(onFocus, enqueuePreload) + props.onMouseEnter = composeHandlers(onMouseEnter, enqueuePreload) + props.onMouseLeave = composeHandlers(onMouseLeave, handleLeave) + props.onTouchStart = composeHandlers(onTouchStart, handleTouchStart) + return applyLinkState(props, options, isActive, href, linkDisabled, host) } const STATIC_EMPTY_OBJECT = {} const STATIC_ACTIVE_OBJECT = { className: 'active' } -// Options consumed by the router and never forwarded to the element. -const LINK_OPTION_KEYS = /* @__PURE__ */ new Set([ - 'activeProps', - 'inactiveProps', - 'activeOptions', +// Options the router consumes; they never reach the element. Every other +// option is an element prop and passes through. +const ROUTER_OPTION_KEYS = /* @__PURE__ */ new Set([ 'to', + 'params', + 'search', + 'hash', + 'state', + 'mask', + 'from', + 'unsafeRelative', + '_fromLocation', + 'reloadDocument', 'preload', 'preloadDelay', 'preloadIntentProximity', @@ -494,101 +450,100 @@ const LINK_OPTION_KEYS = /* @__PURE__ */ new Set([ 'startTransition', 'resetScroll', 'viewTransition', - 'children', - 'target', - 'disabled', - 'style', - 'className', - 'onClick', - 'onBlur', - 'onFocus', - 'onMouseEnter', - 'onMouseLeave', - 'onTouchStart', 'ignoreBlocker', - 'params', - 'search', - 'hash', - 'state', - 'mask', - 'reloadDocument', - 'unsafeRelative', - 'from', - '_fromLocation', + 'activeProps', + 'inactiveProps', + 'activeOptions', '_asChild', ]) -const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true } as const -const STATIC_ACTIVE_PROPS = { - 'data-status': 'active', - 'aria-current': 'page', -} as const - -// Only one state contributes props; merge its class and style with the base ones. -function resolveStateProps( + +// Copies the element props. An object rest would test every key against the +// whole exclusion list; the key set is much cheaper. `Link` hosts never render +// `type`, and an anchor has no `disabled` attribute. +function collectElementProps( + options: object, + host: 'a' | React.ElementType | undefined, +): Record { + const props: Record = {} + for (const key in options) { + if ( + ROUTER_OPTION_KEYS.has(key) || + (key === 'type' && host !== undefined) || + (key === 'disabled' && host === 'a') + ) { + continue + } + props[key] = (options as Record)[key] + } + return props +} + +// Finishes a router-controlled link: the selected state props, then the +// routing attributes. This is the one place that defines precedence: state +// props override element props, `ref` and handlers; `href`, `disabled`, +// `target` and the merged class and style always win. +function applyLinkState( + props: Record, + options: { + activeProps?: unknown + inactiveProps?: unknown + className?: string + style?: React.CSSProperties + target?: string + }, isActive: boolean | undefined, - activeProps: unknown, - inactiveProps: unknown, - className: string | undefined, - style: React.CSSProperties | undefined, -): [ - stateProps: React.HTMLAttributes, - className: string | undefined, - style: React.CSSProperties | undefined, -] { + href: string | undefined, + linkDisabled: boolean, + host: 'a' | React.ElementType | undefined, +): React.ComponentPropsWithRef<'a'> { + const { activeProps, inactiveProps, className, style, target } = options const stateProps: React.HTMLAttributes = functionalUpdate((isActive ? activeProps : inactiveProps) as any, {}) ?? (isActive ? STATIC_ACTIVE_OBJECT : STATIC_EMPTY_OBJECT) - const stateClassName = stateProps.className + Object.assign(props, stateProps) + props.href = href + if (host !== 'a') { + props.disabled = linkDisabled + } + props.target = target + // Merge class and style with the state's. Assign only when one side gave a + // value, so links without them do not carry `undefined` keys. const stateStyle = stateProps.style - return [ - stateProps, - className + if (style !== undefined || stateStyle !== undefined) { + props.style = + style && stateStyle ? { ...style, ...stateStyle } : style || stateStyle + } + const stateClassName = stateProps.className + if (className !== undefined || stateClassName !== undefined) { + props.className = className ? stateClassName ? `${className} ${stateClassName}` : className - : stateClassName, - style && stateStyle ? { ...style, ...stateStyle } : style || stateStyle, - ] + : stateClassName + } + if (linkDisabled) { + props.role = 'link' + props['aria-disabled'] = true + } + if (isActive) { + props['data-status'] = 'active' + props['aria-current'] = 'page' + } + return props } -// Server render of a Link: static props only. This reads the few options it -// needs directly and splits the element props with a key set. V8 checks every -// key of an object rest against the whole exclusion list, which made that -// split the most expensive part of rendering a Link on the server. Only -// server bundles keep this function and the key sets. +// Server render of a Link: static props only, no hooks. Only server bundles +// keep this function; the `isServer` check that calls it folds away on the client. function getServerLinkProps( router: AnyRouter, options: any, forwardedRef: React.ForwardedRef | undefined, host: 'a' | React.ElementType | undefined, ): React.ComponentPropsWithRef<'a'> { - const { - to, - disabled, - activeProps, - inactiveProps, - activeOptions, - children, - target, - style, - className, - } = options as { + const { to, disabled, activeOptions } = options as { to: string | undefined disabled: boolean | undefined - activeProps: unknown - inactiveProps: unknown activeOptions: ActiveOptions | undefined - children: ReactNode - target: string | undefined - style: React.CSSProperties | undefined - className: string | undefined - } - // `Link` additionally never renders a `type` attribute. - const props: Record = {} - for (const key in options) { - if (!LINK_OPTION_KEYS.has(key) && (key !== 'type' || host === undefined)) { - props[key] = options[key] - } } const directExternalLink = resolveExternalLink(to, router.protocolAllowlist) @@ -607,26 +562,10 @@ function getServerLinkProps( directExternalLink ?? (hrefOption && getUrlScheme(hrefOption) ? hrefOption : undefined) - // Assignments below mirror the client's spread order, so props keep the - // same precedence and the rendered attribute order stays identical. + const props = collectElementProps(options, host) + props.ref = forwardedRef if (externalLink) { - props.ref = forwardedRef props.href = externalLink - if (children) { - props.children = children - } - if (target) { - props.target = target - } - if (disabled && host !== 'a') { - props.disabled = disabled - } - if (style) { - props.style = style - } - if (className) { - props.className = className - } return props } @@ -642,33 +581,14 @@ function getServerLinkProps( router.basepath, false, ) - const [resolvedStateProps, resolvedClassName, resolvedStyle] = - resolveStateProps(isActive, activeProps, inactiveProps, className, style) - - // State props override element props and `ref`, but never the routing - // attributes assigned below. - props.ref = forwardedRef - Object.assign(props, resolvedStateProps) - props.href = hrefOption - if (host !== 'a') { - props.disabled = !!linkDisabled - } - props.target = target - if (resolvedStyle) { - props.style = resolvedStyle - } - if (resolvedClassName) { - props.className = resolvedClassName - } - if (linkDisabled) { - props.role = 'link' - props['aria-disabled'] = true - } - if (isActive) { - props['data-status'] = 'active' - props['aria-current'] = 'page' - } - return props + return applyLinkState( + props, + options, + isActive, + hrefOption, + linkDisabled, + host, + ) } const timeoutMap = new WeakMap>()