From 4f64ebd5429afe7d56af7e5464c9d269aa03d18b Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:41:03 +0200 Subject: [PATCH 1/2] perf(router): keep the location cache client-only and slim Link SSR The per-options location cache from the location-reuse changes only ever hits on the client: server renders never repeat an options object, yet every server build still created the WeakMap, looked it up per build, and Solid/Vue server Links stored an entry per render. All three sites are now guarded by `!(isServer ?? this.isServer)`. `isServer` is a per-bundle constant, so server bundles contain no cache at all (the Link SSR bundle has zero references); client bundles keep it unchanged. Rendering a React Link on the server copied its props four times: `Link` split off `_asChild`, `useLinkProps` split the router options off with an object rest, the result was assembled with spreads, and `Link` copied once more to drop `type` and `disabled`. Profiling showed the object rest alone was 85% of the hook's self time: V8 checks every key against the whole 35-entry exclusion list (about 470 ns per call versus 95 ns for a key-set copy). `useLinkProps` is now a wrapper over `useLinkPropsFor(options, ref, host)`. `Link` passes its host (`'a'` or the `createLink` component), so the hook omits `disabled` for anchors and `type` for both, and `Link` passes the result straight to `createElement`. The server branch is a separate `getServerLinkProps` referenced only inside the `isServer` check, so it and its key set are dropped from client bundles; it reads the few options it needs, splits element props with the key set, fills the props object in place with the same precedence and attribute order as before, and no longer runs `useForwardedRef` (moved after the server return). The client keeps its rest destructure; the host handling costs a few bytes there, mostly paid back by the folded router-core guards (numbers below). `encodePathLikeUrl` tests with one merged character class (`/[\s\u0080-\uFFFF]/`), which matches exactly the same code units as the alternation it replaces and is about 10% cheaper. Measurements (macOS arm64, Node 24.8.0, local): - Link SSR paired runner (ABBA, 4 fresh processes per case) against the previous stack top: all 13 cases faster, CPU -19.5% to -40.3%. - Link client paired runner: all 13 cases faster, CPU -4.6% to -9.2%. - Start SSR request loop (benchmarks/ssr, react), interleaved builds: 3.120 -> 2.890 ms per loop (-7.4%), 320.5 -> 346.1 hz. - Client bundle react-router.minimal gzip: 86012 -> 86021 (+9; raw +7, brotli -22): the router-core guards fold to -12, the Link host handling costs +19. The Link SSR bundle shrinks from 197832 to 189989 bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/brisk-links-serve.md | 6 + packages/react-router/src/link.tsx | 336 ++++++++++++++++++++--------- packages/router-core/src/router.ts | 30 ++- packages/router-core/src/utils.ts | 11 +- 4 files changed, 264 insertions(+), 119 deletions(-) create mode 100644 .changeset/brisk-links-serve.md diff --git a/.changeset/brisk-links-serve.md b/.changeset/brisk-links-serve.md new file mode 100644 index 0000000000..fb30ead6ca --- /dev/null +++ b/.changeset/brisk-links-serve.md @@ -0,0 +1,6 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +--- + +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. diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 7420ba71ea..2e1be76c46 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -138,8 +138,59 @@ export function useLinkProps< >( options: UseLinkPropsOptions, forwardedRef?: React.ForwardedRef, +): React.ComponentPropsWithRef<'a'> { + return useLinkPropsFor(options, forwardedRef) +} + +// `host` is what the props are rendered on: `'a'` for `Link`, the component +// given to `createLink`, or `undefined` for the public hook. `Link` never +// renders `type` and an anchor never receives `disabled`, so those are left +// out here rather than copied away from the result in the component. +function useLinkPropsFor< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string = string, + const TTo extends string | undefined = undefined, + const TMaskFrom extends string = TFrom, + const TMaskTo extends string = '', +>( + options: UseLinkPropsOptions, + forwardedRef: React.ForwardedRef | undefined, + host?: 'a' | React.ElementType, ): React.ComponentPropsWithRef<'a'> { const router = useRouter() + + // ========================================================================== + // 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 the location store directly. + // + // Note: `location.hash` is not available on the server. + // ========================================================================== + // The expression must stay inlined in the `if` so bundlers fold the + // browser-build constant `isServer = false` and drop this server block, + // together with `getServerLinkProps` and the key sets only it references. + if (isServer ?? router.isServer) { + return getServerLinkProps(router, options, forwardedRef, host) + } + + // ========================================================================== + // 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 innerRef = useForwardedRef(forwardedRef) const { @@ -179,99 +230,15 @@ export function useLinkProps< unsafeRelative: _unsafeRelative, from: _from, _fromLocation, + _asChild: _asChildOption, + type, ...propsSafeToSpread - } = options + } = options as typeof options & { _asChild?: unknown } const to = toOption as string | undefined - - // ========================================================================== - // 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 the location store directly. - // - // Note: `location.hash` is not available on the server. - // ========================================================================== - // The expression must stay inlined in the `if` so bundlers fold the - // browser-build constant `isServer = false` and drop this server block. - if (isServer ?? router.isServer) { - const directExternalLink = resolveExternalLink(to, router.protocolAllowlist) - - // Direct-scheme links need no route resolution. Blocked links still use - // the shared inactive-prop merge so their server and client markup agree. - const next = - directExternalLink === undefined - ? router.buildLocation(options as any) - : undefined - - const hrefOption = next - ? getHrefOption(next, router, disabled) - : (directExternalLink ?? undefined) - const linkDisabled = disabled || !hrefOption - - const externalLink = - directExternalLink ?? - (hrefOption && getUrlScheme(hrefOption) ? hrefOption : undefined) - - if (externalLink) { - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - href: externalLink, - ...(children && { children }), - ...(target && { target }), - ...(disabled && { disabled }), - ...(style && { style }), - ...(className && { className }), - } - } - - const blockedLink = !disabled && !hrefOption - // Hash is not available on the server, so it never counts as hydrated. - const isActive = - !!next && - !blockedLink && - resolveIsActive( - router.stores.location.get(), - next, - activeOptions, - router.basepath, - false, - ) - const [resolvedStateProps, resolvedClassName, resolvedStyle] = - resolveStateProps(isActive, activeProps, inactiveProps, className, style) - - return { - ...propsSafeToSpread, - // State props may override `ref`, but not on a blocked link (spread first). - ...(blockedLink ? resolvedStateProps : STATIC_EMPTY_OBJECT), - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - ...(!blockedLink ? resolvedStateProps : STATIC_EMPTY_OBJECT), - href: hrefOption, - disabled: !!linkDisabled, - target, - ...(resolvedStyle && { style: resolvedStyle }), - ...(resolvedClassName && { className: resolvedClassName }), - ...(linkDisabled && STATIC_DISABLED_PROPS), - ...(isActive && STATIC_ACTIVE_PROPS), - } + if (host === undefined && type !== undefined) { + ;(propsSafeToSpread as Record).type = type } - // ========================================================================== - // 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() @@ -432,7 +399,7 @@ export function useLinkProps< href: externalLink, ...(children && { children }), ...(target && { target }), - ...(disabled && { disabled }), + ...(disabled && host !== 'a' && { disabled }), ...(style && { style }), ...(className && { className }), ...(onClick && { onClick }), @@ -504,7 +471,7 @@ export function useLinkProps< onTouchStart: composeHandlers(onTouchStart, handleTouchStart), ...(!blockedLink ? resolvedStateProps : STATIC_EMPTY_OBJECT), href, - disabled: !!linkDisabled, + ...(host !== 'a' && { disabled: !!linkDisabled }), target, ...(resolvedStyle && { style: resolvedStyle }), ...(resolvedClassName && { className: resolvedClassName }), @@ -515,8 +482,48 @@ export function useLinkProps< const STATIC_EMPTY_OBJECT = {} const STATIC_ACTIVE_OBJECT = { className: 'active' } -const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true } -const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' } +// Options consumed by the router and never forwarded to the element. +const LINK_OPTION_KEYS = /* @__PURE__ */ new Set([ + 'activeProps', + 'inactiveProps', + 'activeOptions', + 'to', + 'preload', + 'preloadDelay', + 'preloadIntentProximity', + 'hashScrollIntoView', + 'replace', + 'startTransition', + 'resetScroll', + 'viewTransition', + 'children', + 'target', + 'disabled', + 'style', + 'className', + 'onClick', + 'onBlur', + 'onFocus', + 'onMouseEnter', + 'onMouseLeave', + 'onTouchStart', + 'ignoreBlocker', + 'params', + 'search', + 'hash', + 'state', + 'mask', + 'reloadDocument', + 'unsafeRelative', + 'from', + '_fromLocation', + '_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( @@ -546,6 +553,130 @@ function resolveStateProps( ] } +// 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. +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 { + 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) + + // Direct-scheme links need no route resolution. Blocked links still use + // the shared inactive-prop merge so their server and client markup agree. + const next = + directExternalLink === undefined ? router.buildLocation(options) : undefined + + const hrefOption = next + ? getHrefOption(next, router, disabled) + : (directExternalLink ?? undefined) + const linkDisabled = disabled || !hrefOption + + const externalLink = + 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. + 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 + } + + const blockedLink = !disabled && !hrefOption + // Hash is not available on the server, so it never counts as hydrated. + const isActive = + !!next && + !blockedLink && + resolveIsActive( + router.stores.location.get(), + next, + activeOptions, + router.basepath, + false, + ) + const [resolvedStateProps, resolvedClassName, resolvedStyle] = + resolveStateProps(isActive, activeProps, inactiveProps, className, style) + + // State props may override `ref`, but not on a blocked link (assigned first). + if (blockedLink) { + Object.assign(props, resolvedStateProps) + props.ref = forwardedRef + } else { + 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 +} + const timeoutMap = new WeakMap>() const cancelPreload = (eventTarget: object) => { clearTimeout(timeoutMap.get(eventTarget)) @@ -749,24 +880,17 @@ export function createLink( */ export const Link: LinkComponent<'a'> = React.forwardRef( (props, ref) => { - const { _asChild, ...rest } = props - const linkProps = useLinkProps(rest as any, ref) + const host = props._asChild || 'a' + const linkProps = useLinkPropsFor(props as any, ref, host) const children = - typeof rest.children === 'function' - ? rest.children({ + typeof props.children === 'function' + ? props.children({ isActive: (linkProps as any)['data-status'] === 'active', }) - : rest.children + : props.children - if (!_asChild) { - // the ReturnType of useLinkProps returns the correct type for a element, not a general component that has a disabled prop - // @ts-expect-error - const { type: _type, disabled: _, ...anchorProps } = linkProps - return React.createElement('a', anchorProps, children) - } - const { type: _type, ...customProps } = linkProps - return React.createElement(_asChild, customProps, children) + return React.createElement(host, linkProps, children) }, ) as any diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index b12e5df297..e97d1e9603 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1174,7 +1174,9 @@ export class RouterCore< > // Locations built without reading the current location, keyed by the stable // options object a Link owns. Links pass a new object when their values change. - private staticLocations!: WeakMap + // Client only: server renders never repeat an options object, so server + // bundles fold `isServer` and drop the cache entirely. + private staticLocations: WeakMap | undefined isServer!: boolean readonly pathParamsDecoder?: (encoded: string) => string protocolAllowlist!: Set @@ -1250,10 +1252,13 @@ export class RouterCore< ...prevOptions, ...newOptions, } - this.staticLocations = new WeakMap() this.isServer = this.options.isServer ?? isServer ?? typeof document === 'undefined' + // `isServer` is a per-bundle constant, so server builds drop the cache. + if (!(isServer ?? this.isServer)) { + this.staticLocations = new WeakMap() + } this.protocolAllowlist = new Set(this.options.protocolAllowlist) @@ -1390,7 +1395,9 @@ export class RouterCore< setRoutes(caches: RouteTreeCaches) { Object.assign(this, caches) this.lightweightCache = new WeakMap() - this.staticLocations = new WeakMap() + if (!(isServer ?? this.isServer)) { + this.staticLocations = new WeakMap() + } const notFoundRoute = this.options.notFoundRoute @@ -1888,9 +1895,11 @@ export class RouterCore< * @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouterType#buildlocation-method */ buildLocation: BuildLocationFn = (opts) => { - const cached = this.staticLocations.get(opts) - if (cached) { - return cached + if (!(isServer ?? this.isServer)) { + const cached = this.staticLocations!.get(opts) + if (cached) { + return cached + } } // Set by `current()` whenever a build reads the current location. A @@ -2206,8 +2215,13 @@ export class RouterCore< } // Masked locations stay out: `opts.mask` is rebuilt from the current location. - if (!usedCurrent && opts._fromLocation && !next.maskedLocation) { - this.staticLocations.set(opts, next) + if ( + !(isServer ?? this.isServer) && + !usedCurrent && + opts._fromLocation && + !next.maskedLocation + ) { + this.staticLocations!.set(opts, next) } return next diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 9b1b3c411a..936bb3da73 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -708,11 +708,12 @@ export function decodePath(path: string) { * encodePathLikeUrl('/path/already%20encoded') // '/path/already%20encoded' (preserved) */ export function encodePathLikeUrl(path: string): string { - // Encode whitespace and non-ASCII characters that browsers encode in URLs - - // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check - // eslint-disable-next-line no-control-regex - if (!/\s|[^\u0000-\u007F]/.test(path)) return path + // Encode whitespace and non-ASCII characters that browsers encode in URLs. + // The test uses one character class: it matches the same code units as the + // replacement pattern below and is cheaper than the alternation. + if (!/[\s\u0080-\uFFFF]/.test(path)) { + return path + } // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check // eslint-disable-next-line no-control-regex return path.replace(/\s|[^\u0000-\u007F]/gu, encodeURIComponent) From ce9f01b748b0ef89d0c585ec6bcc2ffd780dd37c Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:23:14 +0200 Subject: [PATCH 2/2] refactor(react-router): one state-prop precedence rule for every Link Active and inactive state props overrode `ref` and the event handlers on every link except a blocked one (a destination with a disallowed scheme), where the forwarded ref and the router's handlers won. The exception dates from the hardening change, whose test observed the then-general "router wins" rule on a blocked link; when state props were later allowed to override element props, blocked links were carved out to keep that test green rather than by design. It bought no safety: `href`, `disabled` and `target` are assigned after the state props in both orders, which is what actually keeps a blocked destination inert. It did make the same `inactiveProps` attach their `ref` and `onClick` on one inactive link and silently drop them on another, and it differed from Solid's Link, which applies one rule. Blocked links now follow the same precedence as every other link: state props override element props, `ref` and handlers; the routing attributes always win. The server branch loses its if/else, the client loses the two conditional spreads and the `blockedLink` flag. The blocked-link test now pins the uniform rule together with the routing attributes it already checked. Measurements (macOS arm64, Node 24.8.0, local, against the previous commit): - react-router.minimal gzip 86021 -> 86019 (-2), raw -38, brotli -33. - Link client paired runner (3 repeats): shared-params CPU -11.6% [-15.1, -7.9], encoding -13.2% [-20.2, -5.7]. - Link SSR paired runner: within noise. SSR bundle 189989 -> 189850 bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/brisk-links-serve.md | 2 ++ packages/react-router/src/link.tsx | 20 ++++++---------- .../tests/link-state-props.test.tsx | 24 +++++++++++++++---- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.changeset/brisk-links-serve.md b/.changeset/brisk-links-serve.md index fb30ead6ca..504fd9414a 100644 --- a/.changeset/brisk-links-serve.md +++ b/.changeset/brisk-links-serve.md @@ -4,3 +4,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. diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 2e1be76c46..b0006cb009 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -411,8 +411,6 @@ function useLinkPropsFor< } } - const blockedLink = isActive === undefined - const [resolvedStateProps, resolvedClassName, resolvedStyle] = resolveStateProps(isActive, activeProps, inactiveProps, className, style) @@ -460,8 +458,6 @@ function useLinkPropsFor< return { ...propsSafeToSpread, - // State props may override `ref` and handlers, but not on a blocked link (spread first). - ...(blockedLink ? resolvedStateProps : STATIC_EMPTY_OBJECT), ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers(onClick, handleClick), onBlur: composeHandlers(onBlur, handleLeave), @@ -469,7 +465,9 @@ function useLinkPropsFor< onMouseEnter: composeHandlers(onMouseEnter, enqueuePreload), onMouseLeave: composeHandlers(onMouseLeave, handleLeave), onTouchStart: composeHandlers(onTouchStart, handleTouchStart), - ...(!blockedLink ? resolvedStateProps : STATIC_EMPTY_OBJECT), + // State props override element props, `ref` and handlers, but never the + // routing attributes below. + ...resolvedStateProps, href, ...(host !== 'a' && { disabled: !!linkDisabled }), target, @@ -647,14 +645,10 @@ function getServerLinkProps( const [resolvedStateProps, resolvedClassName, resolvedStyle] = resolveStateProps(isActive, activeProps, inactiveProps, className, style) - // State props may override `ref`, but not on a blocked link (assigned first). - if (blockedLink) { - Object.assign(props, resolvedStateProps) - props.ref = forwardedRef - } else { - props.ref = forwardedRef - Object.assign(props, resolvedStateProps) - } + // 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 diff --git a/packages/react-router/tests/link-state-props.test.tsx b/packages/react-router/tests/link-state-props.test.tsx index a560c5e967..ea45a5e974 100644 --- a/packages/react-router/tests/link-state-props.test.tsx +++ b/packages/react-router/tests/link-state-props.test.tsx @@ -94,7 +94,7 @@ test.each([false, true])( }, ) -test('blocked custom links keep the validated props and forwarded ref', () => { +test('blocked custom links keep the validated routing props and apply state props like any inactive link', () => { const CustomLink = createLink( React.forwardRef< HTMLAnchorElement, @@ -108,13 +108,17 @@ test('blocked custom links keep the validated props and forwarded ref', () => { history: createMemoryHistory(), }) const ref = React.createRef() - const unwantedRef = vi.fn() + const stateRef = vi.fn() + const stateClick = vi.fn() + const baseClick = vi.fn() const search = vi.fn(() => ({})) const buildLocation = vi.spyOn(router, 'buildLocation') const inactiveProps = vi.fn(() => ({ href: 'javascript:override()', disabled: false, - ref: unwantedRef, + target: '_blank', + ref: stateRef, + onClick: stateClick, className: 'inactive-state', title: 'Inactive', })) @@ -124,6 +128,7 @@ test('blocked custom links keep the validated props and forwarded ref', () => { to="javascript:blocked()" ref={ref} search={search} + onClick={baseClick} inactiveProps={inactiveProps} > Target @@ -136,15 +141,24 @@ test('blocked custom links keep the validated props and forwarded ref', () => { const html = renderToString(tree) expect(html).toContain('data-disabled="true"') expect(html).toContain('class="inactive-state"') + expect(html).toContain('title="Inactive"') expect(html).not.toContain('href=') + expect(html).not.toContain('target=') router.isServer = false const anchor = render(tree).getByText('Target') expect(anchor).toHaveAttribute('data-disabled', 'true') expect(anchor).not.toHaveAttribute('href') + expect(anchor).not.toHaveAttribute('target') expect(anchor).toHaveClass('inactive-state') - expect(ref.current).toBe(anchor) + expect(anchor).toHaveAttribute('title', 'Inactive') + // The selected state props win over the forwarded ref and base handlers, + // exactly as they do on any other inactive link. + expect(stateRef).toHaveBeenCalledWith(anchor) + expect(ref.current).toBeNull() + fireEvent.click(anchor) + expect(stateClick).toHaveBeenCalledOnce() + expect(baseClick).not.toHaveBeenCalled() expect(inactiveProps).toHaveBeenCalled() - expect(unwantedRef).not.toHaveBeenCalled() expect(buildLocation).not.toHaveBeenCalled() expect(search).not.toHaveBeenCalled() } finally {