From e3e4aaf7dfaabf32c5036592b286065f9e853060 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Thu, 11 Jun 2026 19:41:29 -0500 Subject: [PATCH] perf(solid-router): proxy-free link props in the spread hot path useLinkProps stacked four proxies (merge of defaults, two omit proxies, and a final merge with the resolved-props memo), and Solid's spread() re-enumerated them through V8 proxy traps on every navigation for every Link. Return a plain object with a stable key set instead, with reactivity in property getters backed by fine-grained memos, and add href-based equality to the built-location memo. Client-side navigation benchmark: 7.06ms -> 4.80ms per iteration. Co-Authored-By: Claude Fable 5 --- .changeset/proxy-free-link-props.md | 23 +++ packages/solid-router/src/link.tsx | 251 ++++++++++++++++------------ 2 files changed, 167 insertions(+), 107 deletions(-) create mode 100644 .changeset/proxy-free-link-props.md diff --git a/.changeset/proxy-free-link-props.md b/.changeset/proxy-free-link-props.md new file mode 100644 index 0000000000..e0f5879cc3 --- /dev/null +++ b/.changeset/proxy-free-link-props.md @@ -0,0 +1,23 @@ +--- +'@tanstack/solid-router': patch +--- + +perf(solid-router): make `useLinkProps` proxy-free in the spread hot path + +`useLinkProps` previously layered four proxies (`merge` for defaults, two +`splitProps`/`omit` proxies, and a final `merge` of spreadable props with the +resolved props memo). Solid's `spread()` re-enumerated all of them through V8 +proxy traps on every navigation, for every `Link`, which showed up in CodSpeed +profiles as a large unattributed "NodeJS internals" cost. + +`useLinkProps` now returns a plain object with a stable key set whose +reactivity lives in property getters backed by fine-grained memos. Values that +no longer apply resolve to `undefined`, which `spread()` treats as attribute +removal. The built-location memo also gained href-based equality so downstream +memos skip work when a navigation doesn't change a link's target. + +This makes the client-side navigation benchmark ~30% faster. + +Note: keys returned by `activeProps`/`inactiveProps` functions are discovered +once at setup — functions that later return brand-new keys (beyond the initial +set plus `class`/`style`) won't have those keys applied. diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 7c4f5032c0..f885b68823 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -73,44 +73,42 @@ export function useLinkProps< let hasRenderFetched = false - const [local, rest] = splitProps( - Solid.merge( - { - activeProps: STATIC_ACTIVE_PROPS_GET, - inactiveProps: STATIC_INACTIVE_PROPS_GET, - }, - options, - ), - [ - 'activeProps', - 'inactiveProps', - 'activeOptions', - 'to', - 'preload', - 'preloadDelay', - 'preloadIntentProximity', - 'hashScrollIntoView', - 'replace', - 'startTransition', - 'resetScroll', - 'viewTransition', - 'target', - 'disabled', - 'style', - 'class', - 'onClick', - 'onBlur', - 'onFocus', - 'onMouseEnter', - 'onMouseLeave', - 'onMouseOver', - 'onMouseOut', - 'onTouchStart', - 'ignoreBlocker', - ], - ) - - const [_, propsSafeToSpread] = splitProps(rest, [ + // Defaults are resolved through accessors at the use sites instead of + // merging them into the props. Every merge/omit proxy layered here gets + // re-enumerated by spread() on each navigation, and V8 dispatches proxy + // traps in native runtime code — keeping this path proxy-free is what + // keeps Link updates cheap. + const local = options + const activeProps = () => local.activeProps ?? STATIC_ACTIVE_PROPS_GET + const inactiveProps = () => local.inactiveProps ?? STATIC_INACTIVE_PROPS_GET + + const propsSafeToSpread = Solid.omit( + options as Record, + 'activeProps', + 'inactiveProps', + 'activeOptions', + 'to', + 'preload', + 'preloadDelay', + 'preloadIntentProximity', + 'hashScrollIntoView', + 'replace', + 'startTransition', + 'resetScroll', + 'viewTransition', + 'target', + 'disabled', + 'style', + 'class', + 'onClick', + 'onBlur', + 'onFocus', + 'onMouseEnter', + 'onMouseLeave', + 'onMouseOver', + 'onMouseOut', + 'onTouchStart', + 'ignoreBlocker', 'params', 'search', 'hash', @@ -119,7 +117,7 @@ export function useLinkProps< 'reloadDocument', 'unsafeRelative', 'from', - ] as any) + ) const currentLocation = Solid.createMemo(() => router.stores.location.get(), { equals: (prev, next) => prev.href === next.href, @@ -135,7 +133,15 @@ export function useLinkProps< // untrack because router-core will also access stores, which are signals in solid return Solid.untrack(() => router.buildLocation(options)) }, - { lazy: true }, + { + lazy: true, + // Navigations usually leave most links' built locations unchanged; + // comparing hrefs lets downstream memos (href, isActive) skip work. + equals: (prev, next) => + prev.href === next.href && + prev.external === next.external && + prev.maskedLocation?.href === next.maskedLocation?.href, + }, ) const hrefOption = Solid.createMemo( @@ -409,8 +415,8 @@ export function useLinkProps< const simpleStyling = Solid.createMemo( () => - local.activeProps === STATIC_ACTIVE_PROPS_GET && - local.inactiveProps === STATIC_INACTIVE_PROPS_GET && + activeProps() === STATIC_ACTIVE_PROPS_GET && + inactiveProps() === STATIC_INACTIVE_PROPS_GET && local.class === undefined && local.style === undefined, { lazy: true }, @@ -444,84 +450,115 @@ export function useLinkProps< style?: JSX.CSSProperties } - const resolvedProps = Solid.createMemo( + const resolvedStateProps = Solid.createMemo( + (): ResolvedLinkStateProps => + (isActive() + ? functionalUpdate(activeProps() as any, {}) + : functionalUpdate(inactiveProps(), {})) ?? EMPTY_OBJECT, + { lazy: true }, + ) + + const resolvedClass = Solid.createMemo( () => { - const active = isActive() - - const base = { - href: hrefOption()?.href, - ref: mergeRefs(setRef, _options().ref as any), - onClick, - onBlur, - onFocus, - onMouseEnter, - onMouseOver, - onMouseLeave, - onMouseOut, - onTouchStart, - disabled: !!local.disabled, - target: local.target, - ...(local.disabled && STATIC_DISABLED_PROPS), - ...(isTransitioning() && STATIC_TRANSITIONING_ATTRIBUTES), - } + if (simpleStyling()) return isActive() ? 'active' : undefined + return ( + [local.class, resolvedStateProps().class].filter(Boolean).join(' ') || + undefined + ) + }, + { lazy: true }, + ) + + const resolvedStyle = Solid.createMemo( + () => { + if (simpleStyling()) return local.style + const style = { ...local.style, ...resolvedStateProps().style } + return hasKeys(style) ? style : undefined + }, + { lazy: true }, + ) - if (simpleStyling()) { - return { - ...base, - ...(active && STATIC_DEFAULT_ACTIVE_ATTRIBUTES), + // The returned object must be a plain object with a stable key set so the + // consuming spread() never enumerates through proxy traps. Reactivity lives + // in the property getters; values that no longer apply resolve to undefined, + // which spread()/assign() treats as attribute removal. Keys returned by + // activeProps/inactiveProps are discovered once at setup. + const extraStateKeys = new Set() + Solid.untrack(() => { + for (const stateProps of [ + functionalUpdate(activeProps() as any, {}), + functionalUpdate(inactiveProps(), {}), + ]) { + if (stateProps) { + for (const key of Object.keys(stateProps)) { + if (key !== 'class' && key !== 'style') extraStateKeys.add(key) } } + } + }) - const activeProps: ResolvedLinkStateProps = active - ? (functionalUpdate(local.activeProps as any, {}) ?? EMPTY_OBJECT) - : EMPTY_OBJECT - const inactiveProps: ResolvedLinkStateProps = active - ? EMPTY_OBJECT - : functionalUpdate(local.inactiveProps, {}) - const style = { - ...local.style, - ...activeProps.style, - ...inactiveProps.style, - } - const className = [local.class, activeProps.class, inactiveProps.class] - .filter(Boolean) - .join(' ') + const composedRef = mergeRefs(setRef, (el: Element) => { + const r = _options().ref as any + if (typeof r === 'function') r(el) + }) - return { - ...activeProps, - ...inactiveProps, - ...base, - ...(hasKeys(style) ? { style } : undefined), - ...(className ? { class: className } : undefined), - ...(active && STATIC_ACTIVE_ATTRIBUTES), - } as ResolvedLinkStateProps - }, - { lazy: true }, - ) + const linkProps: Record = {} + for (const key of Object.keys(propsSafeToSpread)) { + Object.defineProperty( + linkProps, + key, + Object.getOwnPropertyDescriptor(propsSafeToSpread, key)!, + ) + } + for (const key of extraStateKeys) { + Object.defineProperty(linkProps, key, { + get: () => (resolvedStateProps() as Record)[key], + enumerable: true, + configurable: true, + }) + } - return Solid.merge(propsSafeToSpread, resolvedProps) as any + const defineGetters = (getters: Record any>) => { + for (const key of Object.keys(getters)) { + Object.defineProperty(linkProps, key, { + get: getters[key], + enumerable: true, + configurable: true, + }) + } + } + + linkProps.ref = composedRef + linkProps.onClick = onClick + linkProps.onBlur = onBlur + linkProps.onFocus = onFocus + linkProps.onMouseEnter = onMouseEnter + linkProps.onMouseOver = onMouseOver + linkProps.onMouseLeave = onMouseLeave + linkProps.onMouseOut = onMouseOut + linkProps.onTouchStart = onTouchStart + + defineGetters({ + href: () => hrefOption()?.href, + disabled: () => !!local.disabled, + target: () => local.target, + role: () => (local.disabled ? 'link' : undefined), + 'aria-disabled': () => (local.disabled ? 'true' : undefined), + 'data-status': () => (isActive() ? 'active' : undefined), + 'aria-current': () => (isActive() ? 'page' : undefined), + 'data-transitioning': () => + isTransitioning() ? 'transitioning' : undefined, + class: resolvedClass, + style: resolvedStyle, + }) + + return linkProps as any } const STATIC_ACTIVE_PROPS = { class: 'active' } const STATIC_ACTIVE_PROPS_GET = () => STATIC_ACTIVE_PROPS const EMPTY_OBJECT = {} const STATIC_INACTIVE_PROPS_GET = () => EMPTY_OBJECT -const STATIC_DEFAULT_ACTIVE_ATTRIBUTES = { - class: 'active', - 'data-status': 'active', - 'aria-current': 'page', -} -const STATIC_DISABLED_PROPS = { - role: 'link', - 'aria-disabled': 'true', -} -const STATIC_ACTIVE_ATTRIBUTES = { - 'data-status': 'active', - 'aria-current': 'page', -} -const STATIC_TRANSITIONING_ATTRIBUTES = { - 'data-transitioning': 'transitioning', -} /** Call a JSX.EventHandlerUnion with the event. */ function callHandler(