From 7b0f68c341ba3714a7a0ddd13566f4822a24daac Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:14:43 +0200 Subject: [PATCH 01/19] perf(router-core): reduce empty location pipeline work Combine object-form parameter merges into one native call. Allocate a search middleware pipeline only when it is needed, and reuse the existing empty client search value. Preserve callback and structural-sharing behavior. React Link rendering benchmark, measured separately for this commit and its parent using exact production builds: - Workload: benchmarks/client-nav/scenarios/links/react/speed.bench.ts (200 persistent Links, eight navigations per measured batch). - Apple M4, Node 24.20.0, Vitest 4.1.4, NODE_ENV=production. - 4 fresh parent processes and 8 fresh processes for this commit, each with warmupIterations=50 and time=10000 ms; counterbalanced run order. - Parent mean times (ms): 4.4180, 4.0844, 4.1333, 4.2867. - This commit mean times (ms): 3.9412, 3.8998, 3.8950, 4.2674, 4.0423, 3.8793, 3.9043, 4.1120. - Median run means: 4.2100 -> 3.9228 ms. - Incremental effect: 6.82% less time; throughput change +7.32%. - Largest within-run RME for this revision: 1.22%. Incremental minimal/full React gzip impact: +44/+49 bytes. Benchmark sources and commit implementation trees are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/slick-forks-beam.md | 5 +++ packages/router-core/src/router.ts | 29 +++++++++--- .../router-core/tests/build-location.test.ts | 45 +++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 .changeset/slick-forks-beam.md diff --git a/.changeset/slick-forks-beam.md b/.changeset/slick-forks-beam.md new file mode 100644 index 00000000000..125645b9da7 --- /dev/null +++ b/.changeset/slick-forks-beam.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Reduce location-building overhead for object-form path params and empty search middleware pipelines. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e5b6255c63b..fc956d35268 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2761,13 +2761,13 @@ function applySearchMiddleware( destRoutes: ReadonlyArray, includeValidateSearch: boolean | undefined, ) { - const middlewares = [] as Array> + let middlewares: Array> | undefined for (const route of destRoutes) { const routeOptions = route.options if ('search' in routeOptions) { if (routeOptions.search?.middlewares) { - middlewares.push(...routeOptions.search.middlewares) + ;(middlewares ||= []).push(...routeOptions.search.middlewares) } } // TODO remove preSearchFilters and postSearchFilters in v2 @@ -2789,7 +2789,7 @@ function applySearchMiddleware( ) : result } - middlewares.push(legacyMiddleware) + ;(middlewares ||= []).push(legacyMiddleware) } const routeValidateSearch = routeOptions.validateSearch @@ -2813,17 +2813,25 @@ function applySearchMiddleware( return result } - middlewares.push(validate) + ;(middlewares ||= []).push(validate) } } + if (!middlewares?.length) { + if (!dest.search) { + return !isServer && !hasKeys(search) ? search : {} + } + return dest.search === true ? search : functionalUpdate(dest.search, search) + } + const middlewareList = middlewares + const applyNext = ( index: number, currentSearch: any, meta?: SearchMiddlewareMeta, ): any => { // no more middlewares left, return the current search - if (index >= middlewares.length) { + if (index >= middlewareList.length) { if (!dest.search) { return {} } @@ -2848,7 +2856,11 @@ function applySearchMiddleware( return applyNext(index + 1, newSearch, meta) } - return (middlewares[index]! as any)({ search: currentSearch, next, meta }) + return (middlewareList[index]! as any)({ + search: currentSearch, + next, + meta, + }) } return applyNext(0, search) @@ -2884,8 +2896,11 @@ function resolveNextParams( if ((spec ?? true) === true) { return base } + if (typeof spec !== 'function') { + return Object.assign(Object.create(null), base, spec) + } const next = Object.assign(Object.create(null), base) - return Object.assign(next, functionalUpdate(spec as any, next)) + return Object.assign(next, spec(next)) } function extractStrictParams( diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index edb763fa03a..3dee7103d3a 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -234,6 +234,25 @@ describe('buildLocation - params function receives parsed params', () => { }) describe('buildLocation - search params', () => { + test('preserves structural sharing when clearing an already empty search', () => { + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const inherited = router.buildLocation({ to: '/', search: true }) + const cleared = router.buildLocation({ to: '/' }) + + expect(cleared.search).toBe(inherited.search) + expect(cleared.search).toEqual({}) + expect(cleared.searchStr).toBe('') + }) + test('only applies route validation when requested', async () => { const events: Array = [] const validateSearch = vi.fn((search: Record) => { @@ -1653,6 +1672,32 @@ describe('buildLocation - basepath', () => { }) describe('buildLocation - params edge cases', () => { + test('copies static param getters once without mutating inherited params', () => { + const rootRoute = new BaseRootRoute({}) + const userRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/users/$userId', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([userRoute]), + history: createMemoryHistory({ initialEntries: ['/users/123'] }), + }) + const getUserId = vi.fn(() => '456') + const params = { + get userId() { + return getUserId() + }, + } + + expect( + router.buildLocation({ to: '/users/$userId', params }).pathname, + ).toBe('/users/456') + expect(getUserId).toHaveBeenCalledOnce() + expect( + router.buildLocation({ to: '/users/$userId', params: true }).pathname, + ).toBe('/users/123') + }) + test('params: true should preserve current params', async () => { const rootRoute = new BaseRootRoute({}) const userRoute = new BaseRoute({ From 85a9fc4a952693930f6bc90dfc5d8c298be6d24d Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:15:27 +0200 Subject: [PATCH 02/19] perf(react-router): avoid redundant Link state prop allocations Links resolve one state-prop bag instead of separate active and inactive bags. Class names do not need temporary arrays. Unchanged styles retain their original references. Callback behavior and prop precedence remain unchanged. React Link rendering benchmark, measured separately for this commit and its parent using exact production builds: - Workload: benchmarks/client-nav/scenarios/links/react/speed.bench.ts (200 persistent Links, eight navigations per measured batch). - Apple M4, Node 24.20.0, Vitest 4.1.4, NODE_ENV=production. - 8 fresh parent processes and 8 fresh processes for this commit, each with warmupIterations=50 and time=10000 ms; counterbalanced run order. - Parent mean times (ms): 3.9412, 3.8998, 3.8950, 4.2674, 4.0423, 3.8793, 3.9043, 4.1120. - This commit mean times (ms): 3.8767, 3.8900, 4.5507, 4.0116, 3.8297, 3.8582, 3.8879, 4.0157. - Median run means: 3.9228 -> 3.8889 ms. - Incremental effect: 0.86% less time; throughput change +0.87%. - Largest within-run RME for this revision: 3.08%. - The small difference is not a reliable speedup claim; it is close to process-to-process variation. Incremental minimal/full React gzip impact: -12/-7 bytes. Benchmark sources and commit implementation trees are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/honest-nails-burn.md | 5 +++ packages/react-router/src/link.tsx | 39 +++++----------- packages/react-router/tests/link.test.tsx | 54 +++++++++++++++++++++++ 3 files changed, 71 insertions(+), 27 deletions(-) create mode 100644 .changeset/honest-nails-burn.md diff --git a/.changeset/honest-nails-burn.md b/.changeset/honest-nails-burn.md new file mode 100644 index 00000000000..af614527d47 --- /dev/null +++ b/.changeset/honest-nails-burn.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +React Links resolve state props without temporary class-name arrays or unnecessary style copies. diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c6652354c6a..388b9fda937 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -543,32 +543,18 @@ export function useLinkProps< compareLinkState, ) - // Get the active props - const resolvedActiveProps: React.HTMLAttributes = isActive + const resolvedProps: 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, - } + : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT) + const stateClassName = resolvedProps.className + const resolvedClassName = className + ? stateClassName + ? `${className} ${stateClassName}` + : className + : stateClassName + const stateStyle = resolvedProps.style + const resolvedStyle = + style && stateStyle ? { ...style, ...stateStyle } : style || stateStyle // eslint-disable-next-line react-hooks/rules-of-hooks const hasRenderFetched = React.useRef(false) @@ -707,8 +693,7 @@ export function useLinkProps< return { ...propsSafeToSpread, - ...resolvedActiveProps, - ...resolvedInactiveProps, + ...resolvedProps, href, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers(onClick, handleClick), diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 6a4abfe53fc..1bc556d8858 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -8148,3 +8148,57 @@ describe('explicit-undefined params are not collapsed into an empty object', () ) }) }) + +test('reuses unmodified Link styles and merges active styles', async () => { + const baseStyle = { color: 'red', marginTop: 2 } + let renderedStyle: React.CSSProperties | undefined + function StyledLink() { + const props = useLinkProps({ + to: '/target', + style: baseStyle, + className: 'base', + activeProps: () => ({ + className: 'selected', + style: { color: 'blue' }, + }), + inactiveProps: () => ({ className: 'unselected' }), + }) + renderedStyle = props.style + return Styled target + } + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + render() + + const link = await screen.findByRole('link', { name: 'Styled target' }) + expect(renderedStyle).toBe(baseStyle) + expect(link).toHaveClass('base', 'unselected') + + await act(() => router.navigate({ to: '/target' })) + + expect(renderedStyle).toEqual({ color: 'blue', marginTop: 2 }) + expect(link).toHaveClass('base', 'selected') + + await act(() => router.navigate({ to: '/' })) + + expect(renderedStyle).toBe(baseStyle) + expect(link).toHaveClass('base', 'unselected') +}) From 4090ba6f2df94a272c0c653ccb50b51fa682f27d Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:19:03 +0200 Subject: [PATCH 03/19] perf(router-core): share pathname interpolation across Links Reuse the existing segment parser to prepare parameter-name lists. Share pure interpolated path strings through bounded router-scoped SIEVE caches (32 templates, 128 paths per template). Avoid compound-key allocation for single-parameter templates and reuse the current template plan. Parameter callbacks still run before interpolation. Search, hash, state, masks, rewrites, and active-state handling remain independent. Decoder changes clear the caches. No per-Link result cache, descriptor checks, new route matcher, or public API change. React Link rendering benchmark, measured separately for this commit and its parent using exact production builds: - Workload: benchmarks/client-nav/scenarios/links/react/speed.bench.ts (200 persistent Links, eight navigations per measured batch). - Apple M4, Node 24.20.0, Vitest 4.1.4, NODE_ENV=production. - 8 fresh parent processes and 4 fresh processes for this commit, each with warmupIterations=50 and time=10000 ms; counterbalanced run order. - Parent mean times (ms): 3.8767, 3.8900, 4.5507, 4.0116, 3.8297, 3.8582, 3.8879, 4.0157. - This commit mean times (ms): 2.8824, 2.8625, 2.9597, 2.9673. - Median run means: 3.8889 -> 2.9211 ms. - Incremental effect: 24.89% less time; throughput change +33.13%. - Largest within-run RME for this revision: 1.15%. Incremental minimal/full React gzip impact: +251/+265 bytes. Benchmark sources and commit implementation trees are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/mean-mice-design.md | 5 + .../tests/link-destination.test.tsx | 290 ++++++++++++++++++ packages/router-core/src/path.ts | 68 ++++ packages/router-core/src/router.ts | 6 +- packages/router-core/tests/path.test.ts | 103 +++++++ 5 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 .changeset/mean-mice-design.md create mode 100644 packages/react-router/tests/link-destination.test.tsx diff --git a/.changeset/mean-mice-design.md b/.changeset/mean-mice-design.md new file mode 100644 index 00000000000..2225bdac9c2 --- /dev/null +++ b/.changeset/mean-mice-design.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Reuse pathname interpolation across Links with bounded router-scoped caches. Keep parameter callbacks and navigation state independent of the cache. diff --git a/packages/react-router/tests/link-destination.test.tsx b/packages/react-router/tests/link-destination.test.tsx new file mode 100644 index 00000000000..b81d02a7065 --- /dev/null +++ b/packages/react-router/tests/link-destination.test.tsx @@ -0,0 +1,290 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + defaultStringifySearch, + retainSearchParams, +} from '../src' + +describe('Link destination updates', () => { + beforeEach(() => vi.stubEnv('NODE_ENV', 'production')) + afterEach(() => { + cleanup() + vi.unstubAllEnvs() + }) + + function setupFixedLink( + params = { id: 'fixed' }, + stringify?: (params: Record) => { id: string }, + ) { + const rootRoute = createRootRoute({ + component: () => ( + <> + + Target + + + + ), + }) + const itemsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/items/$source', + }) + const targetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/target/$id', + params: { stringify }, + }) + const routeTree = rootRoute.addChildren([itemsRoute, targetRoute]) + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/items/one'] }), + }) + return { router, rootRoute } + } + + test('preserves a fixed destination when unrelated location inputs change', async () => { + const { router } = setupFixedLink() + render() + + const link = await screen.findByTestId('fixed-link') + expect(link).toHaveAttribute('href', '/target/fixed#details') + expect(link).not.toHaveAttribute('data-status') + + await act(() => + router.navigate({ + to: '/items/$source', + params: { source: 'two' }, + search: { tab: 'other' }, + hash: 'other', + }), + ) + + expect(link).toHaveAttribute('href', '/target/fixed#details') + expect(link).not.toHaveAttribute('data-status') + + await act(() => + router.navigate({ + to: '/target/$id', + params: { id: 'fixed' }, + }), + ) + expect(link).toHaveAttribute('data-status', 'active') + expect(link).toHaveAttribute('href', '/target/fixed#details') + }) + + test('updates the destination when router options change', async () => { + const { router } = setupFixedLink() + render() + + const link = await screen.findByTestId('fixed-link') + expect(link).toHaveAttribute('href', '/target/fixed#details') + + router.update({ trailingSlash: 'always' }) + await act(() => + router.navigate({ to: '/items/$source', params: { source: 'two' } }), + ) + expect(link).toHaveAttribute('href', '/target/fixed/#details') + + router.update({ trailingSlash: 'never' }) + await act(() => + router.navigate({ to: '/items/$source', params: { source: 'three' } }), + ) + expect(link).toHaveAttribute('href', '/target/fixed#details') + }) + + test('evaluates param stringifiers before reusing pathnames', async () => { + const stringify = vi.fn((params: Record) => ({ + id: `${params.source}-${params.id}`, + })) + const { router } = setupFixedLink({ id: 'fixed' }, stringify) + render() + + const link = await screen.findByTestId('fixed-link') + expect(link).toHaveAttribute('href', '/target/one-fixed#details') + + await act(() => + router.navigate({ to: '/items/$source', params: { source: 'two' } }), + ) + expect(link).toHaveAttribute('href', '/target/two-fixed#details') + + stringify.mockClear() + await act(() => + router.navigate({ to: '/items/$source', params: { source: 'one' } }), + ) + expect(link).toHaveAttribute('href', '/target/one-fixed#details') + expect(stringify).toHaveBeenCalled() + }) + + test('updates when ancestor search middleware is added and removed', async () => { + const { router, rootRoute } = setupFixedLink() + render() + + const link = await screen.findByTestId('fixed-link') + expect(link).toHaveAttribute('href', '/target/fixed#details') + + rootRoute.update({ + search: { middlewares: [retainSearchParams(true)] }, + }) + await act(() => + router.navigate({ + to: '/items/$source', + params: { source: 'two' }, + search: { retained: 'value' }, + }), + ) + expect(link).toHaveAttribute('href', '/target/fixed?retained=value#details') + + rootRoute.update({ search: undefined }) + await act(() => + router.navigate({ + to: '/items/$source', + params: { source: 'three' }, + search: { retained: 'value' }, + }), + ) + expect(link).toHaveAttribute('href', '/target/fixed#details') + }) + + test('continues evaluating a custom search serializer', async () => { + const { router } = setupFixedLink() + let language = 'en' + router.update({ + stringifySearch: (search) => + defaultStringifySearch({ ...search, language }), + }) + render() + + const link = await screen.findByTestId('fixed-link') + expect(link).toHaveAttribute('href', '/target/fixed?language=en#details') + + language = 'fr' + await act(() => + router.navigate({ to: '/items/$source', params: { source: 'two' } }), + ) + expect(link).toHaveAttribute('href', '/target/fixed?language=fr#details') + }) + + test('reads accessor-backed params after navigation', async () => { + let id = 'one' + const { router } = setupFixedLink({ + get id() { + return id + }, + }) + render() + + const link = await screen.findByTestId('fixed-link') + expect(link).toHaveAttribute('href', '/target/one#details') + + id = 'two' + await act(() => + router.navigate({ to: '/items/$source', params: { source: 'two' } }), + ) + expect(link).toHaveAttribute('href', '/target/two#details') + }) + + test('updates when an existing params object changes', async () => { + const params = { id: 'one' } + const { router } = setupFixedLink(params) + render() + + const link = await screen.findByTestId('fixed-link') + expect(link).toHaveAttribute('href', '/target/one#details') + + params.id = 'two' + await act(() => + router.navigate({ to: '/items/$source', params: { source: 'two' } }), + ) + expect(link).toHaveAttribute('href', '/target/two#details') + }) + + test('updates fixed params and hash when Link props change', async () => { + const rootRoute = createRootRoute({ + component: function Root() { + const [value, setValue] = React.useState('one') + return ( + <> + + + Item + + + + ) + }, + }) + const itemsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/items/$id', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([itemsRoute]), + history: createMemoryHistory({ initialEntries: ['/items/one'] }), + }) + render() + + const link = await screen.findByTestId('changing-link') + expect(link).toHaveAttribute('href', '/items/one#one') + expect(link).toHaveAttribute('data-status', 'active') + + fireEvent.click(screen.getByRole('button', { name: 'Change target' })) + + expect(link).toHaveAttribute('href', '/items/two#two') + expect(link).not.toHaveAttribute('data-status') + }) + + test('updates inherited params and active state together', async () => { + const rootRoute = createRootRoute({ + component: () => ( + <> + + {({ isActive }) => (isActive ? 'Current item' : 'Another item')} + + + + ), + }) + const itemsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/items/$id', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([itemsRoute]), + history: createMemoryHistory({ initialEntries: ['/items/one'] }), + }) + render() + + const link = await screen.findByTestId('inherited-link') + expect(link).toHaveAttribute('href', '/items/one') + expect(link).toHaveTextContent('Current item') + + await act(() => + router.navigate({ to: '/items/$id', params: { id: 'two' } }), + ) + + expect(link).toHaveAttribute('href', '/items/two') + expect(link).toHaveAttribute('data-status', 'active') + expect(link).toHaveTextContent('Current item') + }) +}) diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index d302ee13ab7..359ca316033 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,5 +1,6 @@ import { isServer } from '@tanstack/router-core/isServer' import { last } from './utils' +import { createSieveCache } from './sieve-cache' import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, @@ -224,6 +225,73 @@ interface InterpolatePathOptions { server?: boolean } +/** @internal */ +export function createPathInterpolator() { + type Plan = { + path: string + keys: Array + paths: SieveCache + } + const plans = createSieveCache(32) + let previousPlan: Plan | undefined + let previousDecoder: InterpolatePathOptions['decoder'] + + return (options: InterpolatePathOptions): string => { + const { path, params, decoder } = options + if (!path || path === '/') { + return '/' + } + if (!path.includes('$')) { + return path + } + + if (decoder !== previousDecoder) { + previousDecoder = decoder + previousPlan = undefined + plans.clear() + } + + let plan = previousPlan?.path === path ? previousPlan : plans.get(path) + if (!plan) { + const keys: Array = [] + let cursor = 0 + let segment + while (cursor < path.length) { + segment = parseSegment(path, cursor, segment) + cursor = segment[5] + 1 + if ( + segment[0] === SEGMENT_TYPE_PARAM || + segment[0] === SEGMENT_TYPE_OPTIONAL_PARAM + ) { + keys.push(path.substring(segment[2], segment[3])) + } else if (segment[0] === SEGMENT_TYPE_WILDCARD) { + keys.push('_splat') + } + } + plan = { path, keys, paths: createSieveCache(128) } + plans.set(path, plan) + } + previousPlan = plan + + let key = '' + for (const name of plan.keys) { + const value = params[name] + if (typeof value !== 'string') { + return interpolatePath(options).interpolatedPath + } + key = plan.keys.length === 1 ? value : key + `${value.length}:${value}` + } + + const cached = plan.paths.get(key) + if (cached !== undefined) { + return cached + } + const interpolated = interpolatePath(options).interpolatedPath + plan.paths.set(key, interpolated) + return interpolated + } +} + type InterPolatePathResult = { interpolatedPath: string usedParams: Record diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index fc956d35268..8ea3a1ee1a5 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -24,6 +24,7 @@ import { } from './new-process-route-tree' import { compileDecodeCharMap, + createPathInterpolator, interpolatePath, resolvePath, trimPath, @@ -1132,6 +1133,7 @@ export class RouterCore< routesByPath!: RoutesByPath processedTree!: ProcessedTree resolvePathCache!: SieveCache + private interpolatePath = createPathInterpolator() private routeBranchCache = new WeakMap>() private lightweightCache = new WeakMap< ParsedLocation, @@ -1983,12 +1985,12 @@ export class RouterCore< ? // Keep path params uninterpolated for matchRoute/template matching. nextTo : decodePath( - interpolatePath({ + this.interpolatePath({ path: nextTo, params: nextParams, decoder: this.pathParamsDecoder, server: this.isServer, - }).interpolatedPath, + }), ).path if ( diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 8e4f5ed1db9..e209107276b 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { compileDecodeCharMap, + createPathInterpolator, exactPathTest, interpolatePath, removeTrailingSlash, @@ -18,6 +19,108 @@ import { import { createSieveCache } from '../src/sieve-cache' import type { SegmentKind } from '../src/new-process-route-tree' +describe.each([false, true])( + 'shared pathname interpolation (server: %s)', + (server) => { + it.each([ + { path: '/', params: {} }, + { path: '/users/', params: {} }, + { path: '/users/$id', params: { id: '123' } }, + { path: '/users/$id', params: { id: 0 } }, + { path: '/users/$id', params: {} }, + { path: '/users/$id', params: { id: 'a/b?#@+' } }, + { path: '/users/$id', params: { id: 'cafe\u0301' } }, + { path: '/users/{$id}.json', params: { id: '123' } }, + { path: '/posts/{-$category}', params: {} }, + { path: '/posts/{-$category}', params: { category: undefined } }, + { path: '/posts/{-$category}', params: { category: '' } }, + { path: '/posts/{-$category}', params: { category: 'news' } }, + { + path: '/posts/prefix{-$category}suffix', + params: { category: 'news' }, + }, + { path: '/files/$', params: { _splat: 'a b/c+d' } }, + { path: '/files/prefix{$}suffix', params: { _splat: 'a/b' } }, + { path: '/files/$', params: { _splat: '' } }, + { path: '/$id/$id/', params: { id: '123' } }, + ])('matches interpolation for $path with $params', ({ path, params }) => { + const interpolate = createPathInterpolator() + const options = { path, params, server } + const expected = interpolatePath(options).interpolatedPath + + expect(interpolate(options)).toBe(expected) + expect(interpolate({ ...options, params: { ...params } })).toBe(expected) + }) + + it('shares results across equivalent params without retaining unrelated params', () => { + const interpolate = createPathInterpolator() + const decoder = vi.fn(compileDecodeCharMap(['@'])) + const options = { + path: '/users/$id', + params: { id: '@one', unrelated: 'first' }, + decoder, + server, + } + expect(interpolate(options)).toBe('/users/@one') + expect(decoder).toHaveBeenCalledOnce() + + expect( + interpolate({ + ...options, + params: { id: '@one', unrelated: 'second' }, + }), + ).toBe('/users/@one') + expect(decoder).toHaveBeenCalledOnce() + + const anotherRouter = createPathInterpolator() + expect(anotherRouter(options)).toBe('/users/@one') + expect(decoder).toHaveBeenCalledTimes(2) + }) + + it('invalidates results when allowed-character decoding changes', () => { + const interpolate = createPathInterpolator() + const options = { + path: '/users/$id', + params: { id: '@+' }, + server, + } + expect( + interpolate({ ...options, decoder: compileDecodeCharMap(['@']) }), + ).toBe('/users/@%2B') + expect( + interpolate({ ...options, decoder: compileDecodeCharMap(['+']) }), + ).toBe('/users/%40+') + expect(interpolate(options)).toBe('/users/%40%2B') + }) + + it('does not confuse parameter boundaries in shared cache keys', () => { + const interpolate = createPathInterpolator() + const options = { path: '/$first/$second', server } + const first = { first: 'a:b', second: 'c' } + const second = { first: 'a', second: 'b:c' } + + expect(interpolate({ ...options, params: first })).toBe('/a%3Ab/c') + expect(interpolate({ ...options, params: second })).toBe('/a/b%3Ac') + expect(interpolate({ ...options, params: first })).toBe('/a%3Ab/c') + }) + + it('keeps templates separate when parameter values are equal', () => { + const interpolate = createPathInterpolator() + const params = { id: '123' } + + expect(interpolate({ path: '/users/$id', params, server })).toBe( + '/users/123', + ) + expect(interpolate({ path: '/posts/$id', params, server })).toBe( + '/posts/123', + ) + expect(interpolate({ path: '/users/$id', params, server })).toBe( + '/users/123', + ) + }) + }, +) + describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( 'removeTrailingSlash with basepath $basepath', ({ basepath }) => { From 13254babb3c4e8b1dbd3e8119f3688ee98a72d79 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:27:51 +0200 Subject: [PATCH 04/19] test(router-core): cover shared interpolation cache workloads Cover missing optional parameters that become present, canonical splat keys, and the public usedParams shape. Add focused hit, eviction, and mixed-input benchmarks before simplifying cache metadata collection. Current cache baseline on Node 24.20.0: repeated single-param batches average 0.0050 ms, repeated multi-param batches 0.0305 ms, result eviction 0.1568 ms, template eviction 0.0809 ms, and mixed inputs 0.0370 ms. Miss-heavy runs show outliers and require narrower comparisons. Production cache remains unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/path-interpolation.bench.ts | 102 ++++++++++++++++++ packages/router-core/tests/path.test.ts | 54 ++++++++++ 2 files changed, 156 insertions(+) create mode 100644 packages/router-core/tests/path-interpolation.bench.ts diff --git a/packages/router-core/tests/path-interpolation.bench.ts b/packages/router-core/tests/path-interpolation.bench.ts new file mode 100644 index 00000000000..f8677725445 --- /dev/null +++ b/packages/router-core/tests/path-interpolation.bench.ts @@ -0,0 +1,102 @@ +import { bench, describe, expect } from 'vitest' +import { + compileDecodeCharMap, + createPathInterpolator, + interpolatePath, +} from '../src/path' + +type Options = Parameters>[0] + +const scenarios: Array<{ name: string; inputs: Array }> = [ + { + name: 'single-param shared hits', + inputs: Array.from({ length: 200 }, (_, index) => ({ + path: '/items/$id', + params: { id: `item ${Math.floor(index / 5)}`, unrelated: index }, + })), + }, + { + name: 'multi-param shared hits', + inputs: Array.from({ length: 200 }, (_, index) => ({ + path: '/orgs/$orgId/items/$id', + params: { + orgId: `org:${Math.floor(index / 5) % 4}`, + id: `item/${Math.floor(index / 5)}`, + }, + })), + }, + { + name: 'result eviction', + inputs: Array.from({ length: 256 }, (_, index) => ({ + path: '/items/$id', + params: { id: `item ${index}` }, + })), + }, + { + name: 'template eviction', + inputs: Array.from({ length: 64 }, (_, index) => ({ + path: `/section-${index}/$id`, + params: { id: 'item one' }, + })), + }, + { + name: 'mixed optional and splat params', + inputs: Array.from({ length: 200 }, (_, index) => { + switch (index % 5) { + case 0: + return { path: '/posts/{-$category}', params: {} } + case 1: + return { + path: '/posts/{-$category}', + params: { category: `news-${index % 10}` }, + } + case 2: + return { path: '/users/$id', params: { id: index } } + case 3: + return { path: '/files/$', params: { _splat: `docs/${index % 20}` } } + default: + return { path: '/about', params: {} } + } + }), + }, +] + +const decoder = compileDecodeCharMap(['@', '+']) +for (const { inputs } of scenarios) { + for (const input of inputs) { + input.decoder = decoder + input.server = false + } +} + +describe.each(scenarios)('$name', ({ inputs }) => { + const interpolate = createPathInterpolator() + let checksum = 0 + const expected = inputs.reduce((sum, input) => { + const result = interpolatePath(input).interpolatedPath + expect(interpolate(input)).toBe(result) + return sum + result.length + }, 0) + for (const input of inputs) { + expect(interpolate(input)).toBe(interpolatePath(input).interpolatedPath) + } + + bench( + 'shared interpolation batch', + () => { + let length = 0 + for (const input of inputs) { + length += interpolate(input).length + } + checksum = length + }, + { + time: 1500, + warmupTime: 500, + throws: true, + teardown: () => { + expect(checksum).toBe(expected) + }, + }, + ) +}) diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index e209107276b..02b501e7968 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -118,6 +118,60 @@ describe.each([false, true])( '/users/123', ) }) + + it('tracks optional params that were absent when the template was first used', () => { + const interpolate = createPathInterpolator() + const path = '/posts/{-$category}/$id' + const inputs = [ + { id: 'one' }, + { id: 'one', category: 'news' }, + { id: 'one', category: undefined }, + { id: 'one', category: '' }, + { id: 'one', category: 'updates' }, + ] + + for (const params of [...inputs, ...inputs]) { + const options = { path, params, server } + expect(interpolate(options)).toBe( + interpolatePath(options).interpolatedPath, + ) + } + }) + + it('uses the canonical splat value instead of its legacy alias', () => { + const interpolate = createPathInterpolator() + const decoder = vi.fn(compileDecodeCharMap(['@'])) + const options = { + path: '/files/$', + params: { _splat: 'docs/@guide', '*': 'ignored' }, + decoder, + server, + } + + expect(interpolate(options)).toBe('/files/docs/@guide') + decoder.mockClear() + expect( + interpolate({ + ...options, + params: { _splat: 'docs/@guide', '*': 'changed' }, + }), + ).toBe('/files/docs/@guide') + expect(decoder).not.toHaveBeenCalled() + }) + + it('keeps public usedParams unchanged for missing optionals and splats', () => { + expect( + interpolatePath({ path: '/posts/{-$category}', params: {}, server }) + .usedParams, + ).toEqual({}) + expect( + interpolatePath({ + path: '/files/$', + params: { _splat: 'docs/guide' }, + server, + }).usedParams, + ).toEqual({ _splat: 'docs/guide', '*': 'docs/guide' }) + }) }, ) From 0f17e6b1e35a4a2804b45e9e2fe3a8d3194cd5fa Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:21:51 +0200 Subject: [PATCH 05/19] perf(router-core): reuse interpolation pass for cache keys Collect cache parameter names during the existing interpolation pass instead of parsing each new template twice. Reuse the first interpolated result, compact template plans, and share required/optional/splat path assembly. Preserve public interpolation metadata, missing-param behavior, decoder invalidation, and bounded router-local caches. Fresh existing React Link benchmark against parent cd4010d9cb, Apple M4 / Node 24.20.0 / Vitest 4.1.4 / production: - 200 persistent Links and eight navigations per batch. Four separate processes per version, counterbalanced order, warmupIterations=50, time=10000 ms. - Parent means: 3.0527, 2.8610, 2.8329, 2.8729 ms. - This commit means: 3.1291, 2.8745, 2.8849, 2.8552 ms. - Median run means: 2.8670 -> 2.8797 ms (+0.44% time, effectively flat; no incremental Link speedup claimed). Within-run RME below 1%. - Fresh original-baseline comparisons: 4.2143 -> 2.9825 ms (-29.23%) and 4.0561 -> 2.9909 ms (-26.26%). The complete series still exceeds the original 20% target. Focused interpolation benchmarks, same files/inputs before and after: - Client uncached single-param batches: 90.51 -> 79.91 us (-11.7%). - Client cached template eviction: 72.33 -> 49.74 us (-31.2%; 4-6% RME, p75 also improves). - Client uncached mixed batches: 71.10 -> 52.41 us (-26.3%). - Server uncached mixed batches: 61.42 -> 38.11 us (-38.0%). Bundle size: React minimal/full -414 raw bytes each and -55/-68 gzip bytes. All 18 Router/Start fixtures shrink by 48-75 gzip bytes. Independent hunk attribution confirmed reductions for every retained group. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/loud-times-tie.md | 5 + packages/router-core/src/path.ts | 149 +++++++----------- .../tests/path-interpolation.bench.ts | 25 +++ packages/router-core/tests/path.test.ts | 32 ++++ 4 files changed, 118 insertions(+), 93 deletions(-) create mode 100644 .changeset/loud-times-tie.md diff --git a/.changeset/loud-times-tie.md b/.changeset/loud-times-tie.md new file mode 100644 index 00000000000..71d1ccf62c1 --- /dev/null +++ b/.changeset/loud-times-tie.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Collect shared pathname-cache keys during interpolation instead of parsing templates twice, and share dynamic-segment handling to reduce bundle size. diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 359ca316033..b50713ffe6e 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -3,7 +3,6 @@ import { last } from './utils' import { createSieveCache } from './sieve-cache' import { SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, parseSegment, @@ -223,26 +222,22 @@ interface InterpolatePathOptions { * For testing only, in development mode we use the router.isServer value */ server?: boolean + /** @internal Collect parameter names during the first interpolation. */ + _keys?: Array } /** @internal */ export function createPathInterpolator() { - type Plan = { - path: string - keys: Array - paths: SieveCache - } + type Plan = [keys: Array, paths: SieveCache] const plans = createSieveCache(32) let previousPlan: Plan | undefined + let previousPath: string | undefined let previousDecoder: InterpolatePathOptions['decoder'] return (options: InterpolatePathOptions): string => { const { path, params, decoder } = options - if (!path || path === '/') { - return '/' - } - if (!path.includes('$')) { - return path + if (!path?.includes('$')) { + return path || '/' } if (decoder !== previousDecoder) { @@ -251,43 +246,36 @@ export function createPathInterpolator() { plans.clear() } - let plan = previousPlan?.path === path ? previousPlan : plans.get(path) + let plan = previousPath === path ? previousPlan : plans.get(path) + let interpolated: string | undefined if (!plan) { const keys: Array = [] - let cursor = 0 - let segment - while (cursor < path.length) { - segment = parseSegment(path, cursor, segment) - cursor = segment[5] + 1 - if ( - segment[0] === SEGMENT_TYPE_PARAM || - segment[0] === SEGMENT_TYPE_OPTIONAL_PARAM - ) { - keys.push(path.substring(segment[2], segment[3])) - } else if (segment[0] === SEGMENT_TYPE_WILDCARD) { - keys.push('_splat') - } - } - plan = { path, keys, paths: createSieveCache(128) } + interpolated = interpolatePath({ + ...options, + _keys: keys, + }).interpolatedPath + plan = [keys, createSieveCache(128)] plans.set(path, plan) } previousPlan = plan + previousPath = path + const [keys, paths] = plan let key = '' - for (const name of plan.keys) { + for (const name of keys) { const value = params[name] if (typeof value !== 'string') { - return interpolatePath(options).interpolatedPath + return interpolated ?? interpolatePath(options).interpolatedPath } - key = plan.keys.length === 1 ? value : key + `${value.length}:${value}` + key = keys.length === 1 ? value : key + `${value.length}:${value}` } - const cached = plan.paths.get(key) + const cached = paths.get(key) if (cached !== undefined) { return cached } - const interpolated = interpolatePath(options).interpolatedPath - plan.paths.set(key, interpolated) + interpolated ??= interpolatePath(options).interpolatedPath + paths.set(key, interpolated) return interpolated } } @@ -327,26 +315,20 @@ function encodeParam( * - Encodes params safely (configurable allowed characters) * - Supports `{-$optional}` segments, `{prefix{$id}suffix}` and `{$}` wildcards */ -export function interpolatePath({ - path, - params, - decoder, - // `server` is marked @internal and stripped from .d.ts by `stripInternal`. - // We avoid destructuring it in the function signature so the emitted - // declaration doesn't reference a property that no longer exists. - ...rest -}: InterpolatePathOptions): InterPolatePathResult { +export function interpolatePath( + options: InterpolatePathOptions, +): InterPolatePathResult { + const { path, params, decoder } = options // Tracking if any params are missing in the `params` object // when interpolating the path let isMissingParams = false const usedParams: Record = Object.create(null) - if (!path || path === '/') - return { interpolatedPath: '/', usedParams, isMissingParams } - if (!path.includes('$')) - return { interpolatedPath: path, usedParams, isMissingParams } + if (!path?.includes('$')) { + return { interpolatedPath: path || '/', usedParams, isMissingParams } + } - if (isServer ?? rest.server) { + if (isServer ?? options.server) { // Fast path for common templates like `/posts/$id` or `/files/$`. // Braced segments (`{...}`) are more complex (prefix/suffix/optional) and are // handled by the general parser below. @@ -371,6 +353,7 @@ export function interpolatePath({ // `$id` or `$` (splat). '$' code is 36 if (part.charCodeAt(0) === 36) { if (part.length === 1) { + options._keys?.push('_splat') const splat = params._splat usedParams._splat = splat // TODO: Deprecate * @@ -385,6 +368,7 @@ export function interpolatePath({ joined += '/' + value } else { const key = part.substring(1) + options._keys?.push(key) if (!isMissingParams && !(key in params)) { isMissingParams = true } @@ -424,60 +408,39 @@ export function interpolatePath({ continue } - if (kind === SEGMENT_TYPE_WILDCARD) { - const splat = params._splat - usedParams._splat = splat - // TODO: Deprecate * - usedParams['*'] = splat - - const prefix = path.substring(start, segment[1]) - const suffix = path.substring(segment[4], end) + const splat = kind === SEGMENT_TYPE_WILDCARD + const optional = kind === SEGMENT_TYPE_OPTIONAL_PARAM + const key = splat ? '_splat' : path.substring(segment[2], segment[3]) + options._keys?.push(key) + if (!splat && !optional && !isMissingParams && !(key in params)) { + isMissingParams = true + } + const valueRaw = params[key] + if (optional && valueRaw == null) { + continue + } + usedParams[key] = valueRaw - // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. - if (!splat) { + const prefix = path.substring(start, segment[1]) + const suffix = path.substring(segment[4], end) + if (splat) { + // TODO: Deprecate * + usedParams['*'] = valueRaw + if (!valueRaw) { isMissingParams = true - // For missing splat parameters, just return the prefix and suffix without the wildcard - // If there is a prefix or suffix, return them joined, otherwise omit the segment + // Missing splats retain their prefix/suffix, but not an empty segment. if (prefix || suffix) { joined += '/' + prefix + suffix } continue } - - const value = encodeParam('_splat', params, decoder) - joined += '/' + prefix + value + suffix - continue - } - - if (kind === SEGMENT_TYPE_PARAM) { - const key = path.substring(segment[2], segment[3]) - if (!isMissingParams && !(key in params)) { - isMissingParams = true - } - usedParams[key] = params[key] - - const prefix = path.substring(start, segment[1]) - const suffix = path.substring(segment[4], end) - const value = encodeParam(key, params, decoder) ?? 'undefined' - joined += '/' + prefix + value + suffix - continue - } - - if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - const key = path.substring(segment[2], segment[3]) - const valueRaw = params[key] - - // Check if optional parameter is missing or undefined - if (valueRaw == null) continue - - usedParams[key] = valueRaw - - const prefix = path.substring(start, segment[1]) - const suffix = path.substring(segment[4], end) - const value = encodeParam(key, params, decoder) ?? '' - joined += '/' + prefix + value + suffix - continue } + const value = encodeParam(key, params, decoder) + joined += + '/' + + prefix + + (splat ? value : (value ?? (optional ? '' : 'undefined'))) + + suffix } if (path.endsWith('/')) joined += '/' diff --git a/packages/router-core/tests/path-interpolation.bench.ts b/packages/router-core/tests/path-interpolation.bench.ts index f8677725445..7944100fd03 100644 --- a/packages/router-core/tests/path-interpolation.bench.ts +++ b/packages/router-core/tests/path-interpolation.bench.ts @@ -68,6 +68,12 @@ for (const { inputs } of scenarios) { input.server = false } } +scenarios.push( + ...scenarios.map(({ name, inputs }) => ({ + name: `server ${name}`, + inputs: inputs.map((input) => ({ ...input, server: true })), + })), +) describe.each(scenarios)('$name', ({ inputs }) => { const interpolate = createPathInterpolator() @@ -99,4 +105,23 @@ describe.each(scenarios)('$name', ({ inputs }) => { }, }, ) + + bench( + 'uncached interpolation batch', + () => { + let length = 0 + for (const input of inputs) { + length += interpolatePath(input).interpolatedPath.length + } + checksum = length + }, + { + time: 1500, + warmupTime: 500, + throws: true, + teardown: () => { + expect(checksum).toBe(expected) + }, + }, + ) }) diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 02b501e7968..1e6034ccd9c 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -172,6 +172,38 @@ describe.each([false, true])( }).usedParams, ).toEqual({ _splat: 'docs/guide', '*': 'docs/guide' }) }) + + it('tracks a splat that was empty when the template was first used', () => { + const interpolate = createPathInterpolator() + for (const _splat of ['', 'docs/guide', '', 'docs/reference']) { + const options = { + path: '/files/prefix{$}suffix', + params: { _splat }, + server, + } + expect(interpolate(options)).toBe( + interpolatePath(options).interpolatedPath, + ) + } + }) + + it('does not retain incomplete metadata when the first interpolation throws', () => { + const interpolate = createPathInterpolator() + const options = { path: '/$first/$second', server } + + expect(() => + interpolate({ + ...options, + params: { first: '\uD800', second: 'one' }, + }), + ).toThrow(URIError) + + for (const second of ['two', 'three', 'two']) { + expect( + interpolate({ ...options, params: { first: 'valid', second } }), + ).toBe(`/valid/${second}`) + } + }) }, ) From 0f21fd1977be27d99ce64a235b0d6342f29cc813 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:27:23 +0200 Subject: [PATCH 06/19] perf(router): meet the original Link bundle budgets Move the bounded pathname cache onto RouterCore and reuse one interpolation traversal for pathname generation, cache keys, and optional public metadata. Remove the factory and per-parameter metadata callbacks, preserve decoder invalidation and development server/client overrides, and simplify React Link active-state and state-prop assembly. Public APIs and routing features remain unchanged. Official gzip measurements against parent 37bd31e4e2: - React minimal: 86000 -> 85772 bytes (-228), equal to origin/main. - React full: 89601 -> 89356 bytes (-245), 6 below origin/main. - All 18 Router/Start fixtures shrink by 130-245 gzip bytes. Link remains included; fixtures and the existing Link workload are unchanged. - Independent Link-only changes save 78/86 bytes; core-only changes save 174/169. Combined gzip effects are not additive. Fresh existing production Link benchmark, Apple M4 / Node 24.20.0 / Vitest 4.1.4: 200 persistent Links, eight navigations per batch, 50 warm-up iterations and 10-second measurements, with separate processes in counterbalanced order. - Parent means: 2.903316, 2.908833, 2.945163, 2.955386 ms. - This commit means: 2.941129, 2.941699, 2.920172, 2.970072 ms. - Median run means: 2.926998 -> 2.941414 ms (+0.49% time, near process-to-process noise; no incremental Link speedup claimed). Maximum within-run RME: 0.84%. - Fresh original-baseline comparison: 4.283454 -> 3.016571 ms (-29.58% time). This is the whole-series improvement, not the incremental effect of this commit. Identical focused before/after benchmarks show public helper regressions eliminated, server public families 4.68-12.85% faster, and cached mixed/missing cases 6.50-10.43% faster. Removing the redundant last-template shortcut makes isolated single-template cache hits slower (5.93 -> 7.21 us client per 200 calls; 5.99 -> 6.83 us server). The real Link workload remains effectively flat for this size-reduction step. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/spotty-bats-jog.md | 6 + packages/react-router/src/link.tsx | 68 +++--- packages/router-core/src/path.ts | 217 ++++++++---------- packages/router-core/src/router.ts | 113 +++++++-- .../router-core/tests/build-location.test.ts | 69 ++++++ .../tests/path-interpolation.bench.ts | 69 ++++-- packages/router-core/tests/path.test.ts | 63 ++++- packages/router-core/tests/routerTestUtils.ts | 28 +++ 8 files changed, 430 insertions(+), 203 deletions(-) create mode 100644 .changeset/spotty-bats-jog.md diff --git a/.changeset/spotty-bats-jog.md b/.changeset/spotty-bats-jog.md new file mode 100644 index 00000000000..da338b6d22a --- /dev/null +++ b/.changeset/spotty-bats-jog.md @@ -0,0 +1,6 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +--- + +Reduce the bundle cost of shared Link pathname interpolation while preserving its rendering performance. Reuse one interpolation pass for pathname and optional metadata, keep the bounded cache on the router, and simplify React Link active-state and prop merging. diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 388b9fda937..14b099c328b 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -94,29 +94,17 @@ function resolveIsActive( activeOptions: ActiveOptions | undefined, basepath: string, isHydrated: boolean, - isExternal: boolean, ): boolean { - if (isExternal) { + const currentPath = removeTrailingSlash(location.pathname, basepath) + const nextPath = removeTrailingSlash(next.pathname, basepath) + if ( + currentPath !== nextPath && + (activeOptions?.exact || + !currentPath.startsWith(nextPath) || + currentPath[nextPath.length] !== '/') + ) { return false } - if (activeOptions?.exact) { - const testExact = exactPathTest(location.pathname, next.pathname, basepath) - if (!testExact) { - return false - } - } else { - const currentPathSplit = removeTrailingSlash(location.pathname, basepath) - const nextPathSplit = removeTrailingSlash(next.pathname, basepath) - - const pathIsFuzzyEqual = - currentPathSplit.startsWith(nextPathSplit) && - (currentPathSplit.length === nextPathSplit.length || - currentPathSplit[nextPathSplit.length] === '/') - - if (!pathIsFuzzyEqual) { - return false - } - } if (activeOptions?.includeSearch ?? true) { const searchTest = deepEqual(location.search, next.search, { @@ -523,14 +511,14 @@ export function useLinkProps< return [ hrefOption?.href, externalLink, - resolveIsActive( - location, - next, - stableActiveOptions, - router.basepath, - isHydrated, - externalLink !== undefined, - ), + !externalLink && + resolveIsActive( + location, + next, + stableActiveOptions, + router.basepath, + isHydrated, + ), ] }, [stableActiveOptions, disabled, isHydrated, _options, router, to], @@ -543,18 +531,10 @@ export function useLinkProps< compareLinkState, ) - const resolvedProps: React.HTMLAttributes = isActive - ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT) - : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT) - const stateClassName = resolvedProps.className - const resolvedClassName = className - ? stateClassName - ? `${className} ${stateClassName}` - : className - : stateClassName - const stateStyle = resolvedProps.style - const resolvedStyle = - style && stateStyle ? { ...style, ...stateStyle } : style || stateStyle + const resolvedProps: React.HTMLAttributes = + functionalUpdate(isActive ? activeProps : inactiveProps, {}) ?? + (isActive ? STATIC_ACTIVE_OBJECT : STATIC_EMPTY_OBJECT) + const { className: stateClassName, style: stateStyle } = resolvedProps // eslint-disable-next-line react-hooks/rules-of-hooks const hasRenderFetched = React.useRef(false) @@ -704,8 +684,12 @@ export function useLinkProps< onTouchStart: composeHandlers(onTouchStart, handleTouchStart), disabled: !!disabled, target, - ...(resolvedStyle && { style: resolvedStyle }), - ...(resolvedClassName && { className: resolvedClassName }), + ...(style && { + style: stateStyle ? { ...style, ...stateStyle } : style, + }), + ...(className && { + className: className + (stateClassName ? ' ' + stateClassName : ''), + }), ...(disabled && STATIC_DISABLED_PROPS), ...(isActive && STATIC_ACTIVE_PROPS), } diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index b50713ffe6e..b469ef3f1b5 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,6 +1,5 @@ import { isServer } from '@tanstack/router-core/isServer' import { last } from './utils' -import { createSieveCache } from './sieve-cache' import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PATHNAME, @@ -222,62 +221,6 @@ interface InterpolatePathOptions { * For testing only, in development mode we use the router.isServer value */ server?: boolean - /** @internal Collect parameter names during the first interpolation. */ - _keys?: Array -} - -/** @internal */ -export function createPathInterpolator() { - type Plan = [keys: Array, paths: SieveCache] - const plans = createSieveCache(32) - let previousPlan: Plan | undefined - let previousPath: string | undefined - let previousDecoder: InterpolatePathOptions['decoder'] - - return (options: InterpolatePathOptions): string => { - const { path, params, decoder } = options - if (!path?.includes('$')) { - return path || '/' - } - - if (decoder !== previousDecoder) { - previousDecoder = decoder - previousPlan = undefined - plans.clear() - } - - let plan = previousPath === path ? previousPlan : plans.get(path) - let interpolated: string | undefined - if (!plan) { - const keys: Array = [] - interpolated = interpolatePath({ - ...options, - _keys: keys, - }).interpolatedPath - plan = [keys, createSieveCache(128)] - plans.set(path, plan) - } - previousPlan = plan - previousPath = path - - const [keys, paths] = plan - let key = '' - for (const name of keys) { - const value = params[name] - if (typeof value !== 'string') { - return interpolated ?? interpolatePath(options).interpolatedPath - } - key = keys.length === 1 ? value : key + `${value.length}:${value}` - } - - const cached = paths.get(key) - if (cached !== undefined) { - return cached - } - interpolated ??= interpolatePath(options).interpolatedPath - paths.set(key, interpolated) - return interpolated - } } type InterPolatePathResult = { @@ -288,11 +231,12 @@ type InterPolatePathResult = { function encodeParam( key: string, - params: InterpolatePathOptions['params'], + value: unknown, decoder: InterpolatePathOptions['decoder'], -): any { - const value = params[key] - if (typeof value !== 'string') return value +): string { + if (typeof value !== 'string') { + return '' + (value ?? undefined) + } if (key === '_splat') { // Early return if value only contains URL-safe characters (performance optimization) @@ -318,17 +262,41 @@ function encodeParam( export function interpolatePath( options: InterpolatePathOptions, ): InterPolatePathResult { - const { path, params, decoder } = options - // Tracking if any params are missing in the `params` object - // when interpolating the path - let isMissingParams = false + const { path, params, decoder, server } = options const usedParams: Record = Object.create(null) + let isMissingParams = false + const interpolatedPath = interpolatePathname( + path || '/', + params, + decoder, + usedParams, + undefined, + server, + () => { + isMissingParams = true + }, + ) + return { interpolatedPath, usedParams, isMissingParams } +} - if (!path?.includes('$')) { - return { interpolatedPath: path || '/', usedParams, isMissingParams } +/** + * @internal + * Optional metadata is collected in the same pass as the pathname. + */ +export function interpolatePathname( + path: string, + params: Record, + decoder: InterpolatePathOptions['decoder'], + usedParams?: Record, + keys?: Array, + server?: boolean, + onMissing?: () => void, +): string { + if (!path.includes('$')) { + return path } - if (isServer ?? options.server) { + if (isServer ?? server) { // Fast path for common templates like `/posts/$id` or `/files/$`. // Braced segments (`{...}`) are more complex (prefix/suffix/optional) and are // handled by the general parser below. @@ -339,67 +307,67 @@ export function interpolatePath( while (cursor < length) { // Skip slashes between segments. '/' code is 47 - while (cursor < length && path.charCodeAt(cursor) === 47) cursor++ - if (cursor >= length) break + while (cursor < length && path.charCodeAt(cursor) === 47) { + cursor++ + } + if (cursor >= length) { + break + } const start = cursor let end = path.indexOf('/', cursor) - if (end === -1) end = length + if (end === -1) { + end = length + } cursor = end const part = path.substring(start, end) - if (!part) continue // `$id` or `$` (splat). '$' code is 36 if (part.charCodeAt(0) === 36) { - if (part.length === 1) { - options._keys?.push('_splat') - const splat = params._splat - usedParams._splat = splat - // TODO: Deprecate * - usedParams['*'] = splat - - if (!splat) { - isMissingParams = true - continue - } - - const value = encodeParam('_splat', params, decoder) - joined += '/' + value - } else { - const key = part.substring(1) - options._keys?.push(key) - if (!isMissingParams && !(key in params)) { - isMissingParams = true + const splat = part.length === 1 + const key = splat ? '_splat' : part.substring(1) + const value = params[key] + keys?.push(key) + if (onMissing && !(splat ? value : key in params)) { + onMissing() + onMissing = undefined + } + if (usedParams) { + usedParams[key] = value + if (splat) { + // TODO: Deprecate * + usedParams['*'] = value } - usedParams[key] = params[key] - - const value = encodeParam(key, params, decoder) ?? 'undefined' - joined += '/' + value + } + if (!splat || value) { + joined += '/' + encodeParam(key, value, decoder) } } else { joined += '/' + part } } - if (path.endsWith('/')) joined += '/' + if (path.endsWith('/')) { + joined += '/' + } - const interpolatedPath = joined || '/' - return { usedParams, interpolatedPath, isMissingParams } + return joined || '/' } } - const length = path.length let cursor = 0 let segment let joined = '' - while (cursor < length) { + while (cursor < path.length) { const start = cursor segment = parseSegment(path, start, segment) const end = segment[5] cursor = end + 1 - if (start === end) continue + if (start === end) { + continue + } const kind = segment[0] @@ -411,43 +379,38 @@ export function interpolatePath( const splat = kind === SEGMENT_TYPE_WILDCARD const optional = kind === SEGMENT_TYPE_OPTIONAL_PARAM const key = splat ? '_splat' : path.substring(segment[2], segment[3]) - options._keys?.push(key) - if (!splat && !optional && !isMissingParams && !(key in params)) { - isMissingParams = true - } const valueRaw = params[key] + keys?.push(key) + if (onMissing && !(splat ? valueRaw : optional || key in params)) { + onMissing() + onMissing = undefined + } if (optional && valueRaw == null) { continue } - usedParams[key] = valueRaw + if (usedParams) { + usedParams[key] = valueRaw + if (splat) { + // TODO: Deprecate * + usedParams['*'] = valueRaw + } + } const prefix = path.substring(start, segment[1]) const suffix = path.substring(segment[4], end) - if (splat) { - // TODO: Deprecate * - usedParams['*'] = valueRaw - if (!valueRaw) { - isMissingParams = true - // Missing splats retain their prefix/suffix, but not an empty segment. - if (prefix || suffix) { - joined += '/' + prefix + suffix - } - continue - } + const emptySplat = splat && !valueRaw + if (emptySplat && !prefix && !suffix) { + continue } - const value = encodeParam(key, params, decoder) - joined += - '/' + - prefix + - (splat ? value : (value ?? (optional ? '' : 'undefined'))) + - suffix + const value = emptySplat ? '' : encodeParam(key, valueRaw, decoder) + joined += '/' + prefix + value + suffix } - if (path.endsWith('/')) joined += '/' - - const interpolatedPath = joined || '/' + if (path.endsWith('/')) { + joined += '/' + } - return { usedParams, interpolatedPath, isMissingParams } + return joined || '/' } function encodePathParam( diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 8ea3a1ee1a5..fde94a44275 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2,6 +2,7 @@ import { createBrowserHistory, parseHref } from '@tanstack/history' import { isServer, loadServerRoute } from '@tanstack/router-core/isServer' import { DEFAULT_PROTOCOL_ALLOWLIST, + createNull, decodePath, deepEqual, encodePathLikeUrl, @@ -24,8 +25,7 @@ import { } from './new-process-route-tree' import { compileDecodeCharMap, - createPathInterpolator, - interpolatePath, + interpolatePathname, resolvePath, trimPath, trimPathRight, @@ -993,6 +993,12 @@ type LightweightRouteMatchCacheEntry = [ result: LightweightRouteMatchResult, ] +type InterpolationPlan = [ + keys: Array, + paths: SieveCache, + decoder: ((encoded: string) => string) | undefined, +] + export type CreateRouterFn = < TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption = 'never', @@ -1133,7 +1139,7 @@ export class RouterCore< routesByPath!: RoutesByPath processedTree!: ProcessedTree resolvePathCache!: SieveCache - private interpolatePath = createPathInterpolator() + private pathCache = createSieveCache(32) private routeBranchCache = new WeakMap>() private lightweightCache = new WeakMap< ParsedLocation, @@ -1640,12 +1646,23 @@ export class RouterCore< searchError ??= cause } // Match identity must only use the raw params captured from the URL. - const { interpolatedPath, usedParams } = interpolatePath({ - path: route.fullPath, - params: rawParams, - decoder: this.pathParamsDecoder, - server: this.isServer, - }) + const usedParams: Record = createNull() + const interpolatedPath = + isServer === undefined + ? interpolatePathname( + route.fullPath, + rawParams, + this.pathParamsDecoder, + usedParams, + undefined, + this.isServer, + ) + : interpolatePathname( + route.fullPath, + rawParams, + this.pathParamsDecoder, + usedParams, + ) // Seed planning from the accepted same-ID cache generation first, then // from the committed generation for this route. Presentation stores are @@ -1856,6 +1873,71 @@ export class RouterCore< return result } + private interpolatePath( + path: string, + params: Record, + ): string { + const decoder = this.pathParamsDecoder + let plan = this.pathCache.get(path) + let interpolated: string | undefined + if (!plan || plan[2] !== decoder) { + const keys: Array = [] + interpolated = + isServer === undefined + ? interpolatePathname( + path, + params, + decoder, + undefined, + keys, + this.isServer, + ) + : interpolatePathname(path, params, decoder, undefined, keys) + plan = [keys, createSieveCache(128), decoder] + this.pathCache.set(path, plan) + } + const [keys, paths] = plan + let key = '' + for (const name of keys) { + const value = params[name] + if (typeof value !== 'string') { + return ( + interpolated || + (isServer === undefined + ? interpolatePathname( + path, + params, + decoder, + undefined, + undefined, + this.isServer, + ) + : interpolatePathname(path, params, decoder)) + ) + } + key = keys.length === 1 ? value : key + value.length + ':' + value + } + const cached = paths.get(key) + if (cached) { + return cached + } + paths.set( + key, + (interpolated ||= + isServer === undefined + ? interpolatePathname( + path, + params, + decoder, + undefined, + undefined, + this.isServer, + ) + : interpolatePathname(path, params, decoder)), + ) + return interpolated + } + /** * Build the next ParsedLocation from navigation options without committing. * Resolves `to`/`from`, params/search/hash/state, applies search validation @@ -1966,7 +2048,7 @@ export class RouterCore< route.options.params?.stringify ?? route.options.stringifyParams if (fn) { if (nextParams === fromParams) { - nextParams = Object.assign(Object.create(null), nextParams) + nextParams = Object.assign(createNull(), nextParams) } try { Object.assign(nextParams, fn(nextParams)) @@ -1985,12 +2067,9 @@ export class RouterCore< ? // Keep path params uninterpolated for matchRoute/template matching. nextTo : decodePath( - this.interpolatePath({ - path: nextTo, - params: nextParams, - decoder: this.pathParamsDecoder, - server: this.isServer, - }), + nextTo.includes('$') + ? this.interpolatePath(nextTo, nextParams) + : nextTo, ).path if ( @@ -2127,7 +2206,7 @@ export class RouterCore< this.processedTree, ) if (match) { - const params = Object.assign(Object.create(null), match.rawParams) + const params = Object.assign(createNull(), match.rawParams) const { from: _from, params: maskParams, ...maskProps } = match.route // If mask has a params function, call it with the matched params as context diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 3dee7103d3a..d860d5c395b 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' +import { isServer as serverEnvironment } from '@tanstack/router-core/isServer' +import * as pathUtils from '../src/path' import { BaseRootRoute, BaseRoute, @@ -24,6 +26,73 @@ test('_getUserHistoryState removes volatile router bookkeeping but keeps mask pa ).toEqual({ user: 'state', __tempLocation: {}, __tempKey: 'temp-key' }) }) +test.each([false, true])( + 'forwards the development server override when router.isServer is %s', + (isServer) => { + expect(serverEnvironment).toBeUndefined() + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items/$id', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/items/one'] }), + isServer, + }) + const interpolate = vi.spyOn(pathUtils, 'interpolatePathname') + try { + const matches = router.matchRoutes('/items/one', {}) + expect(matches.at(-1)?.pathname).toBe('/items/one') + const call = interpolate.mock.calls.find( + ([path]) => path === '/items/$id', + ) + expect(call).toHaveLength(6) + expect(call?.[5]).toBe(isServer) + } finally { + interpolate.mockRestore() + } + }, +) + +test('keeps interpolation caches router-local and follows decoder changes', () => { + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items/$id', + }) + const routeTree = rootRoute.addChildren([route]) + const makeRouter = () => + createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const router = makeRouter() + const decoder = vi.fn(pathUtils.compileDecodeCharMap(['@'])) + router.pathParamsDecoder = decoder + + expect( + router.buildLocation({ to: '/items/$id', params: { id: '@one' } }).href, + ).toBe('/items/@one') + decoder.mockClear() + expect( + router.buildLocation({ to: '/items/$id', params: { id: '@one' } }).href, + ).toBe('/items/@one') + expect(decoder).not.toHaveBeenCalled() + + const other = makeRouter() + other.pathParamsDecoder = decoder + expect( + other.buildLocation({ to: '/items/$id', params: { id: '@one' } }).href, + ).toBe('/items/@one') + expect(decoder).toHaveBeenCalledOnce() + + router.pathParamsDecoder = pathUtils.compileDecodeCharMap(['+']) + expect( + router.buildLocation({ to: '/items/$id', params: { id: '@one' } }).href, + ).toBe('/items/%40one') +}) + describe('buildLocation - params function receives parsed params', () => { test('prev params should contain parsed params from route params.parse', async () => { const rootRoute = new BaseRootRoute({}) diff --git a/packages/router-core/tests/path-interpolation.bench.ts b/packages/router-core/tests/path-interpolation.bench.ts index 7944100fd03..baf1afd596e 100644 --- a/packages/router-core/tests/path-interpolation.bench.ts +++ b/packages/router-core/tests/path-interpolation.bench.ts @@ -1,11 +1,10 @@ import { bench, describe, expect } from 'vitest' -import { - compileDecodeCharMap, - createPathInterpolator, - interpolatePath, -} from '../src/path' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute } from '../src' +import { compileDecodeCharMap, interpolatePath } from '../src/path' +import { createTestRouter } from './routerTestUtils' -type Options = Parameters>[0] +type Options = Parameters[0] const scenarios: Array<{ name: string; inputs: Array }> = [ { @@ -59,6 +58,26 @@ const scenarios: Array<{ name: string; inputs: Array }> = [ } }), }, + { + name: 'missing required and splat params', + inputs: Array.from({ length: 200 }, (_, index) => { + switch (index % 5) { + case 0: + return { path: '/$first/$second', params: { second: 'two' } } + case 1: + return { + path: '/$first/$second', + params: { first: undefined, second: 'two' }, + } + case 2: + return { path: '/$first/{-$second}', params: {} } + case 3: + return { path: '/files/$', params: {} } + default: + return { path: '/files/prefix{$}suffix', params: { _splat: '' } } + } + }), + }, ] const decoder = compileDecodeCharMap(['@', '+']) @@ -76,23 +95,41 @@ scenarios.push( ) describe.each(scenarios)('$name', ({ inputs }) => { - const interpolate = createPathInterpolator() + const router = createTestRouter({ + routeTree: new BaseRootRoute({}), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: inputs[0]?.server, + scrollRestoration: false, + }) + router.pathParamsDecoder = decoder + router.history.destroy() + const interpolate = router['interpolatePath'] + const calls = inputs + .filter((input) => input.path?.includes('$')) + .map((input) => ({ + args: + interpolate.length === 1 ? [input] : [input.path || '/', input.params], + expected: interpolatePath(input).interpolatedPath, + })) let checksum = 0 - const expected = inputs.reduce((sum, input) => { - const result = interpolatePath(input).interpolatedPath - expect(interpolate(input)).toBe(result) - return sum + result.length + const expected = inputs.reduce( + (sum, input) => sum + interpolatePath(input).interpolatedPath.length, + 0, + ) + const cachedExpected = calls.reduce((sum, call) => { + expect(Reflect.apply(interpolate, router, call.args)).toBe(call.expected) + return sum + call.expected.length }, 0) - for (const input of inputs) { - expect(interpolate(input)).toBe(interpolatePath(input).interpolatedPath) + for (const call of calls) { + expect(Reflect.apply(interpolate, router, call.args)).toBe(call.expected) } bench( 'shared interpolation batch', () => { let length = 0 - for (const input of inputs) { - length += interpolate(input).length + for (const call of calls) { + length += Reflect.apply(interpolate, router, call.args).length } checksum = length }, @@ -101,7 +138,7 @@ describe.each(scenarios)('$name', ({ inputs }) => { warmupTime: 500, throws: true, teardown: () => { - expect(checksum).toBe(expected) + expect(checksum).toBe(cachedExpected) }, }, ) diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 1e6034ccd9c..92ab3682c09 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { compileDecodeCharMap, - createPathInterpolator, exactPathTest, interpolatePath, removeTrailingSlash, @@ -17,6 +16,7 @@ import { processRouteTree, } from '../src/new-process-route-tree' import { createSieveCache } from '../src/sieve-cache' +import { createTestPathInterpolator as createPathInterpolator } from './routerTestUtils' import type { SegmentKind } from '../src/new-process-route-tree' describe.each([false, true])( @@ -93,6 +93,20 @@ describe.each([false, true])( expect(interpolate(options)).toBe('/users/%40%2B') }) + it('refreshes each cached template when its decoder changes', () => { + const interpolate = createPathInterpolator() + const allowAt = compileDecodeCharMap(['@']) + const allowPlus = compileDecodeCharMap(['+']) + for (const decoder of [allowAt, allowPlus, undefined, allowAt]) { + for (const path of ['/users/$id', '/teams/$id']) { + const options = { path, params: { id: '@+' }, decoder, server } + expect(interpolate(options)).toBe( + interpolatePath(options).interpolatedPath, + ) + } + } + }) + it('does not confuse parameter boundaries in shared cache keys', () => { const interpolate = createPathInterpolator() const options = { path: '/$first/$second', server } @@ -173,6 +187,53 @@ describe.each([false, true])( ).toEqual({ _splat: 'docs/guide', '*': 'docs/guide' }) }) + it.each([ + { + path: '/$first/$second', + params: { second: 'two' }, + pathname: '/undefined/two', + usedParams: { first: undefined, second: 'two' }, + missing: true, + }, + { + path: '/$first/$second', + params: { first: undefined, second: 'two' }, + pathname: '/undefined/two', + usedParams: { first: undefined, second: 'two' }, + missing: false, + }, + { + path: '/{-$first}/$second', + params: { second: 'two' }, + pathname: '/two', + usedParams: { second: 'two' }, + missing: false, + }, + { + path: '/$first/{-$second}', + params: {}, + pathname: '/undefined', + usedParams: { first: undefined }, + missing: true, + }, + { + path: '/$first/prefix{$}suffix', + params: { first: 'one' }, + pathname: '/one/prefixsuffix', + usedParams: { first: 'one', _splat: undefined, '*': undefined }, + missing: true, + }, + ])( + 'preserves missing-param metadata for $path with $params', + ({ path, params, pathname, usedParams, missing }) => { + expect(interpolatePath({ path, params, server })).toEqual({ + interpolatedPath: pathname, + usedParams, + isMissingParams: missing, + }) + }, + ) + it('tracks a splat that was empty when the template was first used', () => { const interpolate = createPathInterpolator() for (const _splat of ['', 'docs/guide', '', 'docs/reference']) { diff --git a/packages/router-core/tests/routerTestUtils.ts b/packages/router-core/tests/routerTestUtils.ts index 413b810b95a..a52f82eb6ad 100644 --- a/packages/router-core/tests/routerTestUtils.ts +++ b/packages/router-core/tests/routerTestUtils.ts @@ -1,11 +1,14 @@ import { batch, createAtom } from '@tanstack/store' +import { createMemoryHistory } from '@tanstack/history' import { isServer } from '@tanstack/router-core/isServer' import { RouterCore, + BaseRootRoute, createNonReactiveMutableStore, createNonReactiveReadonlyStore, } from '../src' import { createRequestHandler } from '../src/ssr/createRequestHandler' +import type { interpolatePath } from '../src/path' import type { RouterHistory } from '@tanstack/history' import type { AnyRouter, @@ -49,6 +52,31 @@ export function createTestRouter< return new RouterCore(options, getStoreConfig) } +export function createTestPathInterpolator() { + const router = createTestRouter({ + routeTree: new BaseRootRoute({}), + history: createMemoryHistory({ initialEntries: ['/'] }), + scrollRestoration: false, + }) + router.history.destroy() + const interpolate = router['interpolatePath'] + + return (options: Parameters[0]): string => { + router.isServer = options.server ?? false + router.pathParamsDecoder = options.decoder + // Support both sides of the factory-to-method benchmark comparison. + const args = + interpolate.length === 1 + ? [options] + : [options.path || '/', options.params] + const result = Reflect.apply(interpolate, router, args) + if (typeof result !== 'string') { + throw new Error('Expected an interpolated pathname') + } + return result + } +} + /** Materialize the request-local server result as the HTTP response users see. */ export function loadServerResponse( router: AnyRouter, From 87aabae95cc6bbd0dea8806cf96c69a83bd19f9d Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:31:12 +0200 Subject: [PATCH 07/19] test(router): add opt-in React Link performance cases Add 13 shared typed workloads measured through production client navigation and real SSR rendering: shared/unique params, updater functions, inheritance, relative targets, middleware chains, numeric parse/stringify, optional/splat segments, encoding, masks, rewrites, and active props with structured search and style merging. Keep all 26 benchmarks outside the existing client-nav/SSR aggregate projects and CodSpeed build graph. The dedicated @benchmarks/react-link-performance targets require TSR_LINK_PERF=1 for discovery; disabled suites import no app. Gate tests cover explicit enablement and production environment selection. Client batches perform eight navigations with 200 persistent measured Links. SSR batches create/load/render/dispose four fresh routers. Independent href, active-state, style, and history-state updater assertions run outside the measured loops. No production packages or dependencies change. Compared identical sources on origin/main 28a5e4504e and captured HEAD bbaa7b3f29, with two fresh processes per ref/mode in counterbalanced order, 50 warm-up iterations, and 3-second windows. Full-suite results show lower HEAD client times for most cases (10.6% shared params, 13.2% search/hash/state updaters, 17.1% masks); SSR updater and optional cases show 14.8% and 12.4% reductions. These are whole-ref comparisons, not improvements from this benchmark-only commit. Run-to-run noise is significant for small differences. Initial middleware and unique-param SSR slowdowns reversed in focused reruns, so they are not established regressions. Preserve both full-suite and focused data in the uncommitted experiment log and session artifacts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- benchmarks/client-nav/README.md | 51 ++ .../client-nav/link-performance/cases.ts | 360 +++++++++++++ .../link-performance/client.bench.ts | 57 ++ .../link-performance/config.test.ts | 36 ++ .../client-nav/link-performance/config.ts | 59 ++ .../client-nav/link-performance/project.json | 74 +++ .../link-performance/src/client.tsx | 29 + .../client-nav/link-performance/src/ssr.tsx | 29 + .../link-performance/src/workload.tsx | 505 ++++++++++++++++++ .../client-nav/link-performance/ssr.bench.ts | 54 ++ .../client-nav/link-performance/tsconfig.json | 15 + .../link-performance/vite.client.config.ts | 3 + .../link-performance/vite.ssr.config.ts | 3 + .../link-performance/vitest.config.ts | 11 + benchmarks/ssr/README.md | 16 + 15 files changed, 1302 insertions(+) create mode 100644 benchmarks/client-nav/link-performance/cases.ts create mode 100644 benchmarks/client-nav/link-performance/client.bench.ts create mode 100644 benchmarks/client-nav/link-performance/config.test.ts create mode 100644 benchmarks/client-nav/link-performance/config.ts create mode 100644 benchmarks/client-nav/link-performance/project.json create mode 100644 benchmarks/client-nav/link-performance/src/client.tsx create mode 100644 benchmarks/client-nav/link-performance/src/ssr.tsx create mode 100644 benchmarks/client-nav/link-performance/src/workload.tsx create mode 100644 benchmarks/client-nav/link-performance/ssr.bench.ts create mode 100644 benchmarks/client-nav/link-performance/tsconfig.json create mode 100644 benchmarks/client-nav/link-performance/vite.client.config.ts create mode 100644 benchmarks/client-nav/link-performance/vite.ssr.config.ts create mode 100644 benchmarks/client-nav/link-performance/vitest.config.ts diff --git a/benchmarks/client-nav/README.md b/benchmarks/client-nav/README.md index 6179c7f0b8c..cecfe926e73 100644 --- a/benchmarks/client-nav/README.md +++ b/benchmarks/client-nav/README.md @@ -115,3 +115,54 @@ Typecheck benchmark sources (baseline + scenarios): ```bash CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:types --outputStyle=stream --skipRemoteCache ``` + +## Opt-in React Link performance suite + +`link-performance/` contains additional client-navigation and SSR workloads for +focused Link work. They are **not included** in the regular client-nav/SSR +aggregate projects or their CodSpeed build dependencies. Benchmark discovery +also requires `TSR_LINK_PERF=1`; without it, no extended benchmark files or app +bundles are imported. + +```bash +TSR_LINK_PERF=1 CI=1 NX_DAEMON=false pnpm nx run @benchmarks/react-link-performance:test:perf:client --outputStyle=stream --skipRemoteCache -- --run +TSR_LINK_PERF=1 CI=1 NX_DAEMON=false pnpm nx run @benchmarks/react-link-performance:test:perf:ssr --outputStyle=stream --skipRemoteCache -- --run + +# Select a feature and save the normal Vitest JSON report. +TSR_LINK_PERF=1 CI=1 NX_DAEMON=false pnpm nx run @benchmarks/react-link-performance:test:perf:client --outputStyle=stream --skipRemoteCache -- --run -t "updater|optional|splat" --outputJson /tmp/link-perf.json +``` + +The cases cover repeated versus unique destination params, updater functions, +relative/inherited values, search middleware chains, param stringification, +optional and splat segments, encoding, masks, basepath/rewrites, and active +props with structured search. These exercise different costs: cache hits and +misses, parameter cloning, callbacks, middleware traversal, URI encoding, +building masked/public locations, active-state comparisons, and prop merging. +Existing preload, mount, and route-tree-scale scenarios remain responsible for +those separate workloads. + +- **Client:** 200 persistent measured Links, four control Links, and eight + completed navigations per timed batch. The existing client harness checks + hrefs and active state during its untimed warm-up lap. +- **SSR:** four fresh-router requests per timed batch, each rendering 200 + measured Links through `RouterProvider` and `renderToString`. Router creation, + `router.load()`, rendering, and history cleanup are included. This isolates + Router SSR Link work, not Start HTTP handling, dehydration, or streaming. + HTML assertions run outside the timed batch. +- Both use the same code-based workload definitions, production JSX/library + builds, 50 warm-up iterations, and three-second measurement windows. The + client and server bundles assert their resolved `isServer` environment. + +Run the gate tests and typecheck separately: + +```bash +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/react-link-performance:test:unit --outputStyle=stream --skipRemoteCache +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/react-link-performance:test:types --outputStyle=stream --skipRemoteCache +``` + +For before/after comparisons, use identical benchmark files and dependencies +on both refs, build each ref through its Nx targets, and alternate fresh +Vitest processes. Report the actual refs, per-case means and relative margins +of error; rerun noisy or borderline results with `-t` rather than interpreting +a small difference as a proven speedup. Client and SSR times have different +batch units and should not be compared directly. diff --git a/benchmarks/client-nav/link-performance/cases.ts b/benchmarks/client-nav/link-performance/cases.ts new file mode 100644 index 00000000000..a25eac563b0 --- /dev/null +++ b/benchmarks/client-nav/link-performance/cases.ts @@ -0,0 +1,360 @@ +export const LINK_CASES = [ + { + id: 'shared-params', + label: 'Shared string params', + description: '200 persistent Links reuse 40 string parameter values.', + }, + { + id: 'unique-params', + label: 'Unique string params', + description: '200 distinct parameter values exceed the 128-result cache.', + }, + { + id: 'param-updaters', + label: 'Parameter updaters', + description: 'Parameter functions derive destinations from current params.', + }, + { + id: 'location-updaters', + label: 'Search, hash and state updaters', + description: + 'Functions derive search, hash and history state from the source.', + }, + { + id: 'relative', + label: 'Relative targets', + description: 'Parent and child targets inherit current params and search.', + }, + { + id: 'middleware', + label: 'Search middleware chain', + description: + 'Retain a tenant, strip a default page and normalize a filter.', + }, + { + id: 'numeric-params', + label: 'Numeric params', + description: 'Parse numbers, update them numerically and stringify them.', + }, + { + id: 'optional-params', + label: 'Optional segments', + description: 'Set, inherit and explicitly clear an optional path segment.', + }, + { + id: 'splats', + label: 'Splat params', + description: 'Empty, multi-segment and encoded splat values.', + }, + { + id: 'encoding', + label: 'Allowed path characters', + description: 'Preserve allowed @, : and + while encoding other characters.', + }, + { + id: 'masks', + label: 'Explicit route masks', + description: + 'Build a different public path and search for each destination.', + }, + { + id: 'rewrites', + label: 'Basepath and rewrites', + description: 'Compose /app with input/output locale path rewrites.', + }, + { + id: 'active', + label: 'Active props and structured search', + description: + 'Exact, fuzzy and search matching with props and render children.', + }, +] as const + +export type LinkCaseId = (typeof LINK_CASES)[number]['id'] + +export const LINK_COUNT = 200 +export const NAVIGATION_STATES = [1, 2, 3, 0] as const +export const LOCALES = ['en', 'fr', 'de', 'es'] as const + +export interface Filter { + tag: string + flags: { open: boolean } + tags: Array +} + +export interface LinkSearch { + page?: number + tenant?: string + filter?: Filter +} + +export function sourceFilter(index: number): Filter { + return { + tag: `group-${index}`, + flags: { open: index % 2 === 0 }, + tags: ['links', `state-${index}`], + } +} + +export function sourceSearch( + caseId: LinkCaseId, + stateIndex: number, +): Partial { + switch (caseId) { + case 'location-updaters': + case 'relative': + case 'middleware': + case 'active': + return { + page: stateIndex + 1, + tenant: `tenant-${stateIndex}`, + filter: sourceFilter(stateIndex), + } + case 'rewrites': + return { tenant: LOCALES[stateIndex] } + default: + return {} + } +} + +export function optionalCategory(stateIndex: number) { + return stateIndex % 2 === 0 ? `category-${stateIndex}` : undefined +} + +export function splatValue(index: number) { + switch (index % 4) { + case 0: + return '' + case 1: + return `folder/sub/file-${index % 40}` + case 2: + return 'a b/100%/caf\u00e9?#' + default: + return 'literal%2F/\u{1f680}' + } +} + +export function encodedValue(index: number) { + return `user-${index % 40}@mail.test:tag+value /?#% caf\u00e9` +} + +function url(pathname: string, search: Partial = {}, hash = '') { + const result = new URL(pathname, 'http://localhost') + for (const [key, value] of Object.entries( + search, + )) { + if (value !== undefined) { + result.searchParams.set( + key, + typeof value === 'object' ? JSON.stringify(value) : String(value), + ) + } + } + result.hash = hash + return result.pathname + result.search + result.hash +} + +function sourcePath(caseId: LinkCaseId, stateIndex: number) { + switch (caseId) { + case 'relative': + return `/teams/team-${stateIndex}/item-${stateIndex}` + case 'numeric-params': + return `/numbers/${stateIndex}` + case 'optional-params': { + const category = optionalCategory(stateIndex) + return `/optional/${category ? `${category}/` : ''}item-${stateIndex}` + } + case 'splats': + return `/files/source/state-${stateIndex}` + case 'encoding': + return `/encoded/source-${stateIndex}` + case 'active': + return `/items/item-${stateIndex}/details` + default: + return `/items/item-${stateIndex}` + } +} + +export function getSourceUrl(caseId: LinkCaseId, stateIndex: number): string { + if (!Number.isInteger(stateIndex) || stateIndex < 0 || stateIndex > 3) { + throw new Error(`Invalid Link performance state: ${stateIndex}`) + } + const pathname = sourcePath(caseId, stateIndex) + if (caseId === 'rewrites') { + return `/app/${LOCALES[stateIndex]}${pathname}` + } + return url( + pathname, + sourceSearch(caseId, stateIndex), + caseId === 'location-updaters' ? `source-${stateIndex}` : '', + ) +} + +// These expectations use fixture values and platform encoding, not router APIs. +function expectedHref(caseId: LinkCaseId, stateIndex: number, index: number) { + const itemId = `item-${index % 40}` + switch (caseId) { + case 'shared-params': + return `/items/${itemId}` + case 'unique-params': + return `/items/item-${index}` + case 'param-updaters': + return `/items/item-${stateIndex}-related-${index % 40}` + case 'location-updaters': + return url( + `/items/item-${stateIndex}`, + { + ...sourceSearch(caseId, stateIndex), + page: stateIndex + 2 + (index % 5), + }, + `source-${stateIndex}-link-${index % 5}`, + ) + case 'relative': + return url( + index % 2 === 0 + ? `/teams/team-${stateIndex}` + : `/teams/team-${stateIndex}/item-${stateIndex}/details`, + sourceSearch(caseId, stateIndex), + ) + case 'middleware': + return url(`/filtered/${itemId}`, { + tenant: `tenant-${stateIndex}`, + page: index % 2 === 0 ? undefined : 2, + filter: { ...sourceFilter(index % 4), tag: `LABEL-${index % 40}` }, + }) + case 'numeric-params': + return `/numbers/${stateIndex + (index % 40)}` + case 'optional-params': { + const category = + index % 3 === 0 + ? 'fixed' + : index % 3 === 1 + ? optionalCategory(stateIndex) + : undefined + return `/optional/${category ? `${category}/` : ''}${itemId}` + } + case 'splats': { + const value = splatValue(index) + return `/files${value ? `/${value.split('/').map(encodeURIComponent).join('/')}` : ''}` + } + case 'encoding': { + const value = encodeURIComponent(encodedValue(index)) + .replaceAll('%40', '@') + .replaceAll('%3A', ':') + .replaceAll('%2B', '+') + return `/encoded/${value}` + } + case 'masks': + return url(`/public/display-${index % 40}`, { + tenant: `mask-${index % 4}`, + }) + case 'rewrites': + return url(`/app/${LOCALES[index % 4]}/items/${itemId}`, { + page: (index % 3) + 1, + }) + case 'active': { + const item = Math.floor(index / 5) + const variant = index % 5 + const search = + variant < 3 + ? { filter: sourceFilter(item % 4) } + : { + ...sourceSearch('active', item % 4), + ...(variant === 4 ? { filter: undefined } : {}), + } + return url(`/items/item-${item}${variant < 2 ? '' : '/details'}`, search) + } + } +} + +function assertUrl( + actualHref: string | null, + expectedHref: string, + label: string, +) { + if (actualHref === null) { + throw new Error(`${label}: missing href`) + } + const actual = new URL(actualHref, 'http://localhost') + const expected = new URL(expectedHref, 'http://localhost') + // Query ordering and the platform's '+' versus '%20' are immaterial here. + const query = (value: URL) => + JSON.stringify([...value.searchParams.entries()].sort()) + if ( + actual.origin !== expected.origin || + actual.pathname !== expected.pathname || + actual.hash !== expected.hash || + query(actual) !== query(expected) + ) { + throw new Error( + `${label}: expected ${expectedHref}, received ${actualHref}`, + ) + } +} + +export function assertScenario( + caseId: LinkCaseId, + stateIndex: number, + root: ParentNode, +): void { + const source = root.querySelector('[data-testid="source-path"]') + if (source?.textContent !== sourcePath(caseId, stateIndex)) { + throw new Error( + `${caseId}/${stateIndex}: unexpected source path ${source?.textContent}`, + ) + } + const links = root.querySelectorAll('a[data-perf-link]') + if (links.length !== LINK_COUNT) { + throw new Error( + `${caseId}: expected ${LINK_COUNT} Links, got ${links.length}`, + ) + } + for (const [index, link] of links.entries()) { + const label = `${caseId}/${stateIndex}/link-${index}` + if (link.getAttribute('data-perf-link') !== String(index)) { + throw new Error(`${label}: unexpected Link order`) + } + assertUrl( + link.getAttribute('href'), + expectedHref(caseId, stateIndex, index), + label, + ) + let active: boolean | undefined + if (caseId === 'shared-params') { + active = index % 40 === stateIndex + } else if (caseId === 'unique-params') { + active = index === stateIndex + } else if (caseId === 'active') { + active = + Math.floor(index / 5) === stateIndex && [0, 2, 3].includes(index % 5) + if ( + link.textContent !== `${index}:${active ? 'active' : 'inactive'}` || + link.getAttribute('title') !== (active ? 'active' : 'inactive') || + !link.classList.contains(active ? 'perf-active' : 'perf-inactive') || + link.style.color !== (active ? 'green' : 'gray') || + link.style.opacity !== '0.8' + ) { + throw new Error( + `${label}: active props or render children are incorrect`, + ) + } + } + if ( + active !== undefined && + (link.getAttribute('data-status') === 'active') !== active + ) { + throw new Error(`${label}: expected active=${active}`) + } + } + for (let state = 0; state < 4; state++) { + const control = root.querySelector(`[data-testid="go-state-${state}"]`) + if (!control || control.hasAttribute('data-perf-link')) { + throw new Error(`${caseId}: missing or measured control Link ${state}`) + } + assertUrl( + control.getAttribute('href'), + getSourceUrl(caseId, state), + `${caseId}/control-${state}`, + ) + } +} diff --git a/benchmarks/client-nav/link-performance/client.bench.ts b/benchmarks/client-nav/link-performance/client.bench.ts new file mode 100644 index 00000000000..21ee7419517 --- /dev/null +++ b/benchmarks/client-nav/link-performance/client.bench.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, bench, describe } from 'vitest' +import { createScenarioSetup } from '../scenarios/harness' +import { benchOptions, ticksPerIteration } from '../scenarios/links/shared' +import { + LINK_CASES, + NAVIGATION_STATES, + assertScenario, + getSourceUrl, +} from './cases' +import type * as App from './src/client' + +const appModulePath = './dist/client/app.js' +const app: typeof App = await import(/* @vite-ignore */ appModulePath) +if (app.serverEnvironment !== false) { + throw new Error('Link client benchmarks must use the production client build') +} + +for (const { id, label } of LINK_CASES) { + describe(label, () => { + let mounted: ReturnType | undefined + const test = createScenarioSetup({ + frameworkLabel: 'React', + mount: (container, history) => { + mounted = app.mountTestApp(container, history, id) + return mounted + }, + initialUrl: getSourceUrl(id, 0), + steps: NAVIGATION_STATES.map((state) => `go-state-${state}`), + assertAfterStep: (index, container) => { + assertScenario(id, NAVIGATION_STATES[index]!, container) + if (!mounted) { + throw new Error('Link benchmark app was not mounted') + } + mounted.assertStateUpdates() + }, + }) + + beforeEach(test.before) + afterEach(test.after) + + bench( + `client Links: ${id}`, + async () => { + for (let index = 0; index < ticksPerIteration; index++) { + await test.tick() + } + await test.finishBatch() + }, + { + ...benchOptions, + time: 3_000, + setup: test.before, + teardown: test.after, + }, + ) + }) +} diff --git a/benchmarks/client-nav/link-performance/config.test.ts b/benchmarks/client-nav/link-performance/config.test.ts new file mode 100644 index 00000000000..e250a5c71b3 --- /dev/null +++ b/benchmarks/client-nav/link-performance/config.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createLinkPerformanceConfig } from './config' + +afterEach(() => vi.unstubAllEnvs()) + +describe.each(['client', 'ssr'] as const)( + '%s opt-in configuration', + (target) => { + test.each([undefined, '', '0', 'false'])( + 'does not discover benchmarks with TSR_LINK_PERF=%s', + (value) => { + vi.stubEnv('TSR_LINK_PERF', value) + const config = createLinkPerformanceConfig(target) + expect(config.test?.benchmark?.include).toEqual([]) + expect(config.test?.passWithNoTests).toBe(true) + }, + ) + + test('discovers only the requested suite when explicitly enabled', () => { + vi.stubEnv('TSR_LINK_PERF', '1') + const config = createLinkPerformanceConfig(target) + expect(config.test?.benchmark?.include).toEqual([`${target}.bench.ts`]) + expect(config.test?.passWithNoTests).toBe(false) + }) + + test('builds production code for the correct environment', () => { + const config = createLinkPerformanceConfig(target) + expect(config.define?.['process.env.NODE_ENV']).toBe('"production"') + expect(config.resolve?.conditions).toContain( + target === 'ssr' ? 'node' : 'browser', + ) + expect(config.build?.ssr).toBe(target === 'ssr') + expect(config.build?.outDir).toBe(`./dist/${target}`) + }) + }, +) diff --git a/benchmarks/client-nav/link-performance/config.ts b/benchmarks/client-nav/link-performance/config.ts new file mode 100644 index 00000000000..7f121046f14 --- /dev/null +++ b/benchmarks/client-nav/link-performance/config.ts @@ -0,0 +1,59 @@ +import { fileURLToPath } from 'node:url' +import react from '@vitejs/plugin-react' +import codspeedPlugin from '@codspeed/vitest-plugin' +import type { UserConfig } from 'vite' + +const root = fileURLToPath(new URL('.', import.meta.url)) + +export function createLinkPerformanceConfig( + target: 'client' | 'ssr', +): UserConfig { + const server = target === 'ssr' + const enabled = process.env.TSR_LINK_PERF === '1' + + return { + root, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + resolve: { + conditions: [server ? 'node' : 'browser', 'production'], + }, + ssr: { + noExternal: true, + resolve: { + conditions: ['node', 'production'], + }, + }, + build: { + outDir: `./dist/${target}`, + emptyOutDir: true, + minify: false, + ssr: server, + lib: { + entry: `${root}src/${target}.tsx`, + formats: ['es'], + fileName: 'app', + }, + rolldownOptions: { + output: { entryFileNames: 'app.js' }, + }, + }, + test: { + name: `react-link-performance-${target}`, + watch: false, + environment: server ? 'node' : 'jsdom', + setupFiles: server ? [] : ['../vitest.setup.ts'], + include: [], + passWithNoTests: !enabled, + benchmark: { + include: enabled ? [`${target}.bench.ts`] : [], + }, + }, + } +} diff --git a/benchmarks/client-nav/link-performance/project.json b/benchmarks/client-nav/link-performance/project.json new file mode 100644 index 00000000000..9fbfb69036c --- /dev/null +++ b/benchmarks/client-nav/link-performance/project.json @@ -0,0 +1,74 @@ +{ + "name": "@benchmarks/react-link-performance", + "projectType": "application", + "tags": ["optional-benchmark"], + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config ./link-performance/vite.client.config.ts", + "cwd": "benchmarks/client-nav" + } + }, + "build:ssr": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config ./link-performance/vite.ssr.config.ts", + "cwd": "benchmarks/client-nav" + } + }, + "test:perf:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": ["build:client"], + "options": { + "command": "NODE_ENV=production vitest bench --config ./link-performance/vite.client.config.ts", + "cwd": "benchmarks/client-nav" + } + }, + "test:perf:ssr": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": ["build:ssr"], + "options": { + "command": "NODE_ENV=production vitest bench --config ./link-performance/vite.ssr.config.ts", + "cwd": "benchmarks/client-nav" + } + }, + "test:unit": { + "executor": "nx:run-commands", + "dependsOn": [], + "options": { + "command": "vitest run --config ./link-performance/vitest.config.ts", + "cwd": "benchmarks/client-nav" + } + }, + "test:types": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p ./link-performance/tsconfig.json --noEmit", + "cwd": "benchmarks/client-nav" + } + } + } +} diff --git a/benchmarks/client-nav/link-performance/src/client.tsx b/benchmarks/client-nav/link-performance/src/client.tsx new file mode 100644 index 00000000000..a187ee8c159 --- /dev/null +++ b/benchmarks/client-nav/link-performance/src/client.tsx @@ -0,0 +1,29 @@ +import { RouterProvider } from '@tanstack/react-router' +import { isServer } from '@tanstack/router-core/isServer' +import { createRoot } from 'react-dom/client' +import { assertStateUpdates, createLinkRouter } from './workload' +import type { LinkRouter } from './workload' +import type { RouterHistory } from '@tanstack/history' +import type { LinkCaseId } from '../cases' + +export const serverEnvironment: boolean | undefined = isServer + +export function mountTestApp( + container: HTMLElement, + history: RouterHistory, + caseId: LinkCaseId, +): { + router: LinkRouter + unmount: () => void + assertStateUpdates: () => void +} { + const router = createLinkRouter(caseId, history, false) + const root = createRoot(container) + root.render() + + return { + router, + unmount: () => root.unmount(), + assertStateUpdates: () => assertStateUpdates(router), + } +} diff --git a/benchmarks/client-nav/link-performance/src/ssr.tsx b/benchmarks/client-nav/link-performance/src/ssr.tsx new file mode 100644 index 00000000000..491191b1f97 --- /dev/null +++ b/benchmarks/client-nav/link-performance/src/ssr.tsx @@ -0,0 +1,29 @@ +import { RouterProvider, createMemoryHistory } from '@tanstack/react-router' +import { isServer } from '@tanstack/router-core/isServer' +import { renderToString } from 'react-dom/server' +import { getSourceUrl } from '../cases' +import { assertStateUpdates, createLinkRouter } from './workload' +import type { LinkCaseId } from '../cases' + +export const serverEnvironment: boolean | undefined = isServer + +export async function renderScenario( + caseId: LinkCaseId, + stateIndex: number, + verify = false, +) { + const history = createMemoryHistory({ + initialEntries: [getSourceUrl(caseId, stateIndex)], + }) + const router = createLinkRouter(caseId, history, true) + try { + await router.load() + const html = renderToString() + if (verify) { + assertStateUpdates(router) + } + return html + } finally { + history.destroy() + } +} diff --git a/benchmarks/client-nav/link-performance/src/workload.tsx b/benchmarks/client-nav/link-performance/src/workload.tsx new file mode 100644 index 00000000000..85b7261d9c1 --- /dev/null +++ b/benchmarks/client-nav/link-performance/src/workload.tsx @@ -0,0 +1,505 @@ +import { + Link, + Outlet, + createRootRouteWithContext, + createRoute, + createRouter, + linkOptions, + retainSearchParams, + stripSearchParams, + useLocation, +} from '@tanstack/react-router' +import { + LINK_COUNT, + LOCALES, + encodedValue, + optionalCategory, + sourceFilter, + sourceSearch, + splatValue, +} from '../cases' +import type { RouterHistory } from '@tanstack/history' +import type { SearchSchemaInput } from '@tanstack/react-router' +import type { Filter, LinkCaseId, LinkSearch } from '../cases' + +interface StateUpdates { + calls: number + verifiedCalls: number + input: number + output: number + offset: number +} + +interface WorkloadContext { + caseId: LinkCaseId + stateUpdates: StateUpdates +} + +function isFilter(value: unknown): value is Filter { + return ( + typeof value === 'object' && + value !== null && + 'tag' in value && + typeof value.tag === 'string' && + 'flags' in value && + typeof value.flags === 'object' && + value.flags !== null && + 'open' in value.flags && + typeof value.flags.open === 'boolean' && + 'tags' in value && + Array.isArray(value.tags) && + value.tags.every((tag: unknown) => typeof tag === 'string') + ) +} + +const rootRoute = createRootRouteWithContext()({ + validateSearch: ( + search: Record & SearchSchemaInput, + ): LinkSearch => ({ + page: typeof search.page === 'number' ? search.page : undefined, + tenant: typeof search.tenant === 'string' ? search.tenant : undefined, + filter: isFilter(search.filter) ? search.filter : undefined, + }), + component: RootLayout, +}) + +const itemsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'items/$itemId', +}) +const detailsRoute = createRoute({ + getParentRoute: () => itemsRoute, + path: 'details', +}) +const teamRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'teams/$teamId', +}) +const teamItemRoute = createRoute({ + getParentRoute: () => teamRoute, + path: '$itemId', +}) +const teamDetailsRoute = createRoute({ + getParentRoute: () => teamItemRoute, + path: 'details', +}) +const filteredRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'filtered/$itemId', + search: { + middlewares: [ + retainSearchParams(['tenant']), + stripSearchParams({ page: 1 }), + ({ search, next }) => { + const result = next(search) + return { + ...result, + filter: result.filter + ? { ...result.filter, tag: result.filter.tag.trim().toUpperCase() } + : undefined, + } + }, + ], + }, +}) +const numericRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'numbers/$number', + params: { + parse: ({ number }) => { + const value = Number(number) + if (!Number.isFinite(value)) { + throw new Error(`Invalid number: ${number}`) + } + return { number: value } + }, + stringify: ({ number }) => ({ number: String(number) }), + }, +}) +const optionalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'optional/{-$category}/$itemId', +}) +const splatRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'files/$', +}) +const encodedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'encoded/$value', +}) +const publicRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'public/$itemId', +}) + +const routeTree = rootRoute.addChildren([ + itemsRoute.addChildren([detailsRoute]), + teamRoute.addChildren([teamItemRoute.addChildren([teamDetailsRoute])]), + filteredRoute, + numericRoute, + optionalRoute, + splatRoute, + encodedRoute, + publicRoute, +]) + +const localeRewrite = { + input: ({ url }: { url: URL }) => { + const match = /^\/(en|fr|de|es)(\/.*)$/.exec(url.pathname) + if (match) { + url.pathname = match[2]! + url.searchParams.set('tenant', match[1]!) + } + return url + }, + output: ({ url }: { url: URL }) => { + const locale = url.searchParams.get('tenant') + if (locale) { + url.searchParams.delete('tenant') + url.pathname = `/${locale}${url.pathname}` + } + return url + }, +} + +export function createLinkRouter( + caseId: LinkCaseId, + history: RouterHistory, + isServer: boolean, +) { + const context: WorkloadContext = { + caseId, + stateUpdates: { + calls: 0, + verifiedCalls: 0, + input: 0, + output: 0, + offset: 0, + }, + } + return createRouter({ + routeTree, + history, + isServer, + context, + scrollRestoration: false, + defaultPreload: false, + trailingSlash: 'never', + pathParamsAllowedCharacters: + caseId === 'encoding' ? ['@', ':', '+'] : undefined, + ...(caseId === 'rewrites' + ? { basepath: '/app', rewrite: localeRewrite } + : {}), + }) +} + +export type LinkRouter = ReturnType + +declare module '@tanstack/react-router' { + interface Register { + router: LinkRouter + } +} + +declare module '@tanstack/history' { + interface HistoryState { + linkPerfState?: number + } +} + +// Call alongside DOM assertions, outside the timed loop, after each sample. +export function assertStateUpdates(router: LinkRouter): void { + const { caseId, stateUpdates } = router.options.context + if (caseId !== 'location-updaters') { + return + } + const input = router.state.location.state.linkPerfState ?? 0 + if ( + stateUpdates.calls - stateUpdates.verifiedCalls < LINK_COUNT || + stateUpdates.input !== input || + stateUpdates.output !== input + stateUpdates.offset || + stateUpdates.offset < 1 || + stateUpdates.offset > 40 + ) { + throw new Error( + 'Link history-state updaters did not run with current state', + ) + } + stateUpdates.verifiedCalls = stateUpdates.calls +} + +function sourceOptions(caseId: LinkCaseId, stateIndex: number) { + const itemId = `item-${stateIndex}` + const common = { + search: sourceSearch(caseId, stateIndex), + hash: caseId === 'location-updaters' ? `source-${stateIndex}` : '', + state: { linkPerfState: stateIndex + 10 }, + } + switch (caseId) { + case 'relative': + return linkOptions({ + ...common, + to: '/teams/$teamId/$itemId', + params: { teamId: `team-${stateIndex}`, itemId }, + }) + case 'numeric-params': + return linkOptions({ + ...common, + to: '/numbers/$number', + params: { number: stateIndex }, + }) + case 'optional-params': + return linkOptions({ + ...common, + to: '/optional/{-$category}/$itemId', + params: { category: optionalCategory(stateIndex), itemId }, + }) + case 'splats': + return linkOptions({ + ...common, + to: '/files/$', + params: { _splat: `source/state-${stateIndex}` }, + }) + case 'encoding': + return linkOptions({ + ...common, + to: '/encoded/$value', + params: { value: `source-${stateIndex}` }, + }) + case 'active': + return linkOptions({ + ...common, + to: '/items/$itemId/details', + params: { itemId }, + }) + default: + return linkOptions({ + ...common, + to: '/items/$itemId', + params: { itemId }, + }) + } +} + +const indexes = Array.from({ length: LINK_COUNT }, (_, index) => index) +const controls = [0, 1, 2, 3] as const + +function SourcePath() { + const pathname = useLocation({ select: (location) => location.pathname }) + return {pathname} +} + +function RootLayout() { + const context = rootRoute.useRouteContext() + return ( + <> + + +
+ {indexes.map((index) => measuredLink(context, index))} +
+ + + ) +} + +function measuredLink( + { caseId, stateUpdates }: WorkloadContext, + index: number, +) { + const common = { + key: index, + 'data-perf-link': index, + preload: false, + children: `Link ${index}`, + } as const + const itemId = `item-${index % 40}` + switch (caseId) { + case 'shared-params': + case 'unique-params': + return ( + + ) + case 'param-updaters': + return ( + ({ + itemId: `${previous.itemId}-related-${index % 40}`, + })} + /> + ) + case 'location-updaters': + return ( + ({ + ...previous, + page: (previous.page ?? 1) + 1 + (index % 5), + })} + hash={(previous) => `${previous}-link-${index % 5}`} + state={(previous) => { + const input = previous.linkPerfState ?? 0 + const offset = (index % 40) + 1 + const output = input + offset + stateUpdates.calls++ + stateUpdates.input = input + stateUpdates.output = output + stateUpdates.offset = offset + return { ...previous, linkPerfState: output } + }} + /> + ) + case 'relative': + return ( + + ) + case 'middleware': + return ( + + ) + case 'numeric-params': + return ( + ({ number: previous.number + (index % 40) })} + /> + ) + case 'optional-params': + return ( + + ) + case 'splats': + return ( + + ) + case 'encoding': + return ( + + ) + case 'masks': + return ( + + ) + case 'rewrites': + return ( + + ) + case 'active': { + const item = Math.floor(index / 5) + const variant = index % 5 + return ( + ({ + className: 'perf-active', + title: 'active', + style: { color: 'green' }, + })} + inactiveProps={() => ({ + className: 'perf-inactive', + title: 'inactive', + style: { color: 'gray' }, + })} + > + {({ isActive }) => `${index}:${isActive ? 'active' : 'inactive'}`} + + ) + } + } +} diff --git a/benchmarks/client-nav/link-performance/ssr.bench.ts b/benchmarks/client-nav/link-performance/ssr.bench.ts new file mode 100644 index 00000000000..7b9662640b5 --- /dev/null +++ b/benchmarks/client-nav/link-performance/ssr.bench.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, bench, describe } from 'vitest' +import { JSDOM } from 'jsdom' +import { benchOptions } from '../scenarios/links/shared' +import { LINK_CASES, NAVIGATION_STATES, assertScenario } from './cases' +import type * as App from './src/ssr' + +const appUrl = new URL('./dist/ssr/app.js', import.meta.url).href +const app: typeof App = await import(/* @vite-ignore */ appUrl) +if (app.serverEnvironment !== true) { + throw new Error('Link SSR benchmarks must use the production server build') +} + +for (const { id, label } of LINK_CASES) { + describe(label, () => { + let html = '' + let state = 0 + + function assertHtml() { + const dom = new JSDOM(html) + try { + assertScenario(id, state, dom.window.document) + } finally { + dom.window.close() + } + } + + async function prepare() { + for (const nextState of NAVIGATION_STATES) { + state = nextState + html = await app.renderScenario(id, state, true) + assertHtml() + } + } + + beforeEach(prepare) + afterEach(assertHtml) + + bench( + `SSR Links: ${id}`, + async () => { + for (const nextState of NAVIGATION_STATES) { + state = nextState + html = await app.renderScenario(id, state) + } + }, + { + ...benchOptions, + time: 3_000, + setup: prepare, + teardown: assertHtml, + }, + ) + }) +} diff --git a/benchmarks/client-nav/link-performance/tsconfig.json b/benchmarks/client-nav/link-performance/tsconfig.json new file mode 100644 index 00000000000..fbf230c9770 --- /dev/null +++ b/benchmarks/client-nav/link-performance/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "./**/*.ts", + "./**/*.tsx", + "../scenarios/harness.ts", + "../setup-helpers.ts", + "../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/link-performance/vite.client.config.ts b/benchmarks/client-nav/link-performance/vite.client.config.ts new file mode 100644 index 00000000000..1cd45fc45ef --- /dev/null +++ b/benchmarks/client-nav/link-performance/vite.client.config.ts @@ -0,0 +1,3 @@ +import { createLinkPerformanceConfig } from './config' + +export default createLinkPerformanceConfig('client') diff --git a/benchmarks/client-nav/link-performance/vite.ssr.config.ts b/benchmarks/client-nav/link-performance/vite.ssr.config.ts new file mode 100644 index 00000000000..f59148eaf60 --- /dev/null +++ b/benchmarks/client-nav/link-performance/vite.ssr.config.ts @@ -0,0 +1,3 @@ +import { createLinkPerformanceConfig } from './config' + +export default createLinkPerformanceConfig('ssr') diff --git a/benchmarks/client-nav/link-performance/vitest.config.ts b/benchmarks/client-nav/link-performance/vitest.config.ts new file mode 100644 index 00000000000..a9b4cf69729 --- /dev/null +++ b/benchmarks/client-nav/link-performance/vitest.config.ts @@ -0,0 +1,11 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + test: { + watch: false, + environment: 'node', + include: ['config.test.ts'], + }, +}) diff --git a/benchmarks/ssr/README.md b/benchmarks/ssr/README.md index 625e1006c5b..1f1c2329225 100644 --- a/benchmarks/ssr/README.md +++ b/benchmarks/ssr/README.md @@ -98,3 +98,19 @@ Use `react`, `solid`, or `vue` for ``. The baseline projects use `@be - Server-function loops must include `sec-fetch-site: same-origin` so the default CSRF middleware accepts the request. - Loops that expect non-200 responses pass a custom `validateResponse` to `runRequestLoop`. - Bench loops must build deterministic requests from the seeded random helper and consume response bodies through `runRequestLoop` or `runSsrRequestLoop`. + +## Optional React Link workloads + +The [opt-in Link suite](../client-nav/README.md#opt-in-react-link-performance-suite) +adds focused standalone React Router SSR coverage for params/updaters, +inheritance, search middlewares, optional/splat segments, encoding, masks, +rewrites, and active props. Each timed batch creates four fresh routers and +renders their Links to HTML; it does not measure Start HTTP/streaming overhead. + +```bash +TSR_LINK_PERF=1 CI=1 NX_DAEMON=false pnpm nx run @benchmarks/react-link-performance:test:perf:ssr --outputStyle=stream --skipRemoteCache -- --run +``` + +These cases are excluded from this directory's aggregate projects and normal +CodSpeed dependency graph. The flag must be explicitly enabled when invoking +the dedicated target. From 4cb5df2c781d21973e32e30292934e85dbeeeacd Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:46:32 +0200 Subject: [PATCH 08/19] test(router): stabilize opt-in Link performance comparisons Bound control navigation history with replace and assert that the measured anchors remain mounted. Share the exact setup, batch, and post-measurement assertions between normal Vitest benches and the paired comparison runner. Increase normal warm-up and measurement windows. Add an opt-in stable runner with a fresh process per case/replica, separate router/app modules, one shared production React runtime, deterministic V8 random/hash seeds, alternating initialization order, and ABBA/BAAB fixed-work blocks. Record main-thread CPU, wall time, whole-process CPU, bundle hashes, and all raw blocks. Derive per-case 95% intervals from independent process replicas rather than correlated individual batches; require CPU and wall results to corroborate a direction. Same-code controls exposed substantial noise in earlier methods. The selected control was centered near zero (client CPU -0.95%, SSR CPU -0.56%), with intervals still several percentage points wide. Keep unresolved measurements explicitly inconclusive instead of labeling them regressions. Compared fixed runtime refs origin/main 28a5e4504e and bbaa7b3f29 across all 26 cases with four fresh-process replicas each: 16 cases support speedups, 10 remain inconclusive, and none support a slowdown in both metrics. Client middleware CPU: -5.95% [-13.89%, +2.73%]; SSR middleware: -16.06% [-31.65%, +3.09%]; SSR unique params: -5.08% [-14.97%, +5.96%]. These do not establish middleware or unique-param regressions. No production package code or default CodSpeed workflow changes. Keep the complete raw matrix, calibration trials, snapshots, and detailed conclusions in uncommitted session artifacts and LOG.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- benchmarks/client-nav/README.md | 54 +++- .../link-performance/client.bench.ts | 60 ++-- .../link-performance/config.test.ts | 21 ++ .../client-nav/link-performance/config.ts | 14 +- .../client-nav/link-performance/project.json | 8 + .../client-nav/link-performance/scenario.ts | 114 ++++++++ .../link-performance/src/workload.tsx | 1 + .../client-nav/link-performance/ssr.bench.ts | 49 +--- .../link-performance/stable-runner.ts | 274 ++++++++++++++++++ .../link-performance/stable-worker.ts | 112 +++++++ .../link-performance/statistics.test.ts | 36 +++ .../client-nav/link-performance/statistics.ts | 57 ++++ .../link-performance/vitest.config.ts | 2 +- .../link-performance/worker-protocol.ts | 28 ++ 14 files changed, 745 insertions(+), 85 deletions(-) create mode 100644 benchmarks/client-nav/link-performance/scenario.ts create mode 100644 benchmarks/client-nav/link-performance/stable-runner.ts create mode 100644 benchmarks/client-nav/link-performance/stable-worker.ts create mode 100644 benchmarks/client-nav/link-performance/statistics.test.ts create mode 100644 benchmarks/client-nav/link-performance/statistics.ts create mode 100644 benchmarks/client-nav/link-performance/worker-protocol.ts diff --git a/benchmarks/client-nav/README.md b/benchmarks/client-nav/README.md index cecfe926e73..3cf38f87dee 100644 --- a/benchmarks/client-nav/README.md +++ b/benchmarks/client-nav/README.md @@ -143,15 +143,20 @@ those separate workloads. - **Client:** 200 persistent measured Links, four control Links, and eight completed navigations per timed batch. The existing client harness checks - hrefs and active state during its untimed warm-up lap. + hrefs and active state during its untimed warm-up lap. Control navigations + replace the history entry, keeping history size constant. Post-measurement + assertions also check that the measured anchors stayed mounted. - **SSR:** four fresh-router requests per timed batch, each rendering 200 measured Links through `RouterProvider` and `renderToString`. Router creation, `router.load()`, rendering, and history cleanup are included. This isolates Router SSR Link work, not Start HTTP handling, dehydration, or streaming. HTML assertions run outside the timed batch. -- Both use the same code-based workload definitions, production JSX/library - builds, 50 warm-up iterations, and three-second measurement windows. The - client and server bundles assert their resolved `isServer` environment. +- Both use the same code-based workload definitions and production JSX/library + builds. The regular Vitest entry points use at least 100 warm-up iterations, + one second of warm-up time, and five-second measurement windows. The client + and server bundles assert their resolved `isServer` environment. React and + React DOM remain external so comparisons can share the same renderer runtime. + These bundles are Node-hosted (jsdom for client mode), not browser deployments. Run the gate tests and typecheck separately: @@ -166,3 +171,44 @@ Vitest processes. Report the actual refs, per-case means and relative margins of error; rerun noisy or borderline results with `-t` rather than interpreting a small difference as a proven speedup. Client and SSR times have different batch units and should not be compared directly. + +### Stable paired comparisons + +For regression decisions, prefer the paired runner over a whole-file Vitest +run. It starts a fresh process for every case and repetition, avoiding JIT +feedback from earlier cases. Inside each process, both revisions share the +same production React installation but have separate router/app modules and +router instances. Initialization order alternates between repetitions. + +```bash +# Build the baseline using these same benchmark sources in its own checkout. +# --baseline points to that checkout's link-performance/dist directory. +TSR_LINK_PERF=1 pnpm nx run @benchmarks/react-link-performance:test:perf:stable -- \ + --baseline /path/to/baseline/benchmarks/client-nav/link-performance/dist \ + --outputJson /tmp/paired-links.json + +# Narrow a comparison, or increase independent process repetitions. +TSR_LINK_PERF=1 pnpm nx run @benchmarks/react-link-performance:test:perf:stable -- \ + --baseline /path/to/baseline/benchmarks/client-nav/link-performance/dist \ + --mode ssr -t "middleware|unique-params" --repeats 6 \ + --outputJson /tmp/paired-links-ssr.json +``` + +The runner requires Node with `process.threadCpuUsage` (Node 24 works). +It fixes V8 random/hash seeds, warms each variant for at least two seconds +and 100 batches, then alternates ABBA/BAAB blocks calibrated to roughly 500 ms. +Both variants do exactly the same number of batches per block. It records +main-thread CPU time, wall time, and whole-process CPU time; GC during +measurement is not disabled or discarded. + +The default is four independent process repetitions. Reported 95% intervals +use their paired log-ratios, not the many correlated batches as independent +samples. A faster/slower verdict requires CPU and wall intervals to agree. +Intervals overlapping zero or disagreeing metrics are inconclusive; narrow +intervals entirely inside +/-2% are reported separately. + +An A/A calibration uses the current `dist` directory as `--baseline`. Its +intervals should contain zero before trusting similarly sized A/B differences. +Shared-machine contention can still make small changes unresolved. Do not +interpret a point estimate alone, or an inconclusive result, as proof that +a workload is unchanged. diff --git a/benchmarks/client-nav/link-performance/client.bench.ts b/benchmarks/client-nav/link-performance/client.bench.ts index 21ee7419517..086fadd1619 100644 --- a/benchmarks/client-nav/link-performance/client.bench.ts +++ b/benchmarks/client-nav/link-performance/client.bench.ts @@ -1,12 +1,6 @@ import { afterEach, beforeEach, bench, describe } from 'vitest' -import { createScenarioSetup } from '../scenarios/harness' -import { benchOptions, ticksPerIteration } from '../scenarios/links/shared' -import { - LINK_CASES, - NAVIGATION_STATES, - assertScenario, - getSourceUrl, -} from './cases' +import { LINK_CASES } from './cases' +import { createClientScenario, samplingOptions } from './scenario' import type * as App from './src/client' const appModulePath = './dist/client/app.js' @@ -17,41 +11,21 @@ if (app.serverEnvironment !== false) { for (const { id, label } of LINK_CASES) { describe(label, () => { - let mounted: ReturnType | undefined - const test = createScenarioSetup({ - frameworkLabel: 'React', - mount: (container, history) => { - mounted = app.mountTestApp(container, history, id) - return mounted - }, - initialUrl: getSourceUrl(id, 0), - steps: NAVIGATION_STATES.map((state) => `go-state-${state}`), - assertAfterStep: (index, container) => { - assertScenario(id, NAVIGATION_STATES[index]!, container) - if (!mounted) { - throw new Error('Link benchmark app was not mounted') - } - mounted.assertStateUpdates() - }, - }) - - beforeEach(test.before) - afterEach(test.after) + const scenario = createClientScenario(app, id) + function finish() { + try { + scenario.check() + } finally { + scenario.teardown() + } + } + beforeEach(scenario.setup) + afterEach(finish) - bench( - `client Links: ${id}`, - async () => { - for (let index = 0; index < ticksPerIteration; index++) { - await test.tick() - } - await test.finishBatch() - }, - { - ...benchOptions, - time: 3_000, - setup: test.before, - teardown: test.after, - }, - ) + bench(`client Links: ${id}`, scenario.batch, { + ...samplingOptions, + setup: scenario.setup, + teardown: finish, + }) }) } diff --git a/benchmarks/client-nav/link-performance/config.test.ts b/benchmarks/client-nav/link-performance/config.test.ts index e250a5c71b3..3f2624677f2 100644 --- a/benchmarks/client-nav/link-performance/config.test.ts +++ b/benchmarks/client-nav/link-performance/config.test.ts @@ -31,6 +31,27 @@ describe.each(['client', 'ssr'] as const)( ) expect(config.build?.ssr).toBe(target === 'ssr') expect(config.build?.outDir).toBe(`./dist/${target}`) + expect(config.build?.rolldownOptions?.platform).toBe('node') + expect(config.build?.rolldownOptions?.external).toEqual([ + 'node:module', + 'module', + /^react(?:\/|$)/, + /^react-dom(?:\/|$)/, + ]) + }) + + test('does not retransform built bundles in the test environment', () => { + vi.stubEnv('VITEST', 'true') + const config = createLinkPerformanceConfig(target) + expect(config.ssr?.noExternal).toBeUndefined() + expect(config.test?.server?.deps?.external).toEqual([ + /\/link-performance\/dist\//, + ]) + }) + + test('bundles router dependencies when building app snapshots', () => { + vi.stubEnv('VITEST', undefined) + expect(createLinkPerformanceConfig(target).ssr?.noExternal).toBe(true) }) }, ) diff --git a/benchmarks/client-nav/link-performance/config.ts b/benchmarks/client-nav/link-performance/config.ts index 7f121046f14..ef5e4f29ca7 100644 --- a/benchmarks/client-nav/link-performance/config.ts +++ b/benchmarks/client-nav/link-performance/config.ts @@ -25,7 +25,7 @@ export function createLinkPerformanceConfig( conditions: [server ? 'node' : 'browser', 'production'], }, ssr: { - noExternal: true, + noExternal: process.env.VITEST ? undefined : true, resolve: { conditions: ['node', 'production'], }, @@ -41,6 +41,13 @@ export function createLinkPerformanceConfig( fileName: 'app', }, rolldownOptions: { + platform: 'node', + external: [ + 'node:module', + 'module', + /^react(?:\/|$)/, + /^react-dom(?:\/|$)/, + ], output: { entryFileNames: 'app.js' }, }, }, @@ -49,6 +56,11 @@ export function createLinkPerformanceConfig( watch: false, environment: server ? 'node' : 'jsdom', setupFiles: server ? [] : ['../vitest.setup.ts'], + server: { + deps: { + external: [/\/link-performance\/dist\//], + }, + }, include: [], passWithNoTests: !enabled, benchmark: { diff --git a/benchmarks/client-nav/link-performance/project.json b/benchmarks/client-nav/link-performance/project.json index 9fbfb69036c..548a92c8725 100644 --- a/benchmarks/client-nav/link-performance/project.json +++ b/benchmarks/client-nav/link-performance/project.json @@ -49,6 +49,14 @@ "cwd": "benchmarks/client-nav" } }, + "test:perf:stable": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": ["build:client", "build:ssr"], + "options": { + "command": "NODE_ENV=production node --import=@swc-node/register/esm-register benchmarks/client-nav/link-performance/stable-runner.ts" + } + }, "test:unit": { "executor": "nx:run-commands", "dependsOn": [], diff --git a/benchmarks/client-nav/link-performance/scenario.ts b/benchmarks/client-nav/link-performance/scenario.ts new file mode 100644 index 00000000000..f1a3fb6df7e --- /dev/null +++ b/benchmarks/client-nav/link-performance/scenario.ts @@ -0,0 +1,114 @@ +import { JSDOM } from 'jsdom' +import { createScenarioSetup } from '../scenarios/harness' +import { ticksPerIteration } from '../scenarios/links/shared' +import { NAVIGATION_STATES, assertScenario, getSourceUrl } from './cases' +import type { LinkCaseId } from './cases' +import type * as ClientApp from './src/client' +import type * as SsrApp from './src/ssr' + +export interface LinkScenario { + setup: () => Promise + batch: () => Promise + check: () => void + teardown: () => void +} + +export const samplingOptions = { + warmupIterations: 100, + warmupTime: 1_000, + time: 5_000, + throws: true, +} + +export function createClientScenario( + app: typeof ClientApp, + id: LinkCaseId, +): LinkScenario { + let mounted: ReturnType | undefined + let container: HTMLElement | undefined + let anchors: Array = [] + const test = createScenarioSetup({ + frameworkLabel: 'React', + mount: (element, history) => { + container = element + mounted = app.mountTestApp(element, history, id) + return mounted + }, + initialUrl: getSourceUrl(id, 0), + steps: NAVIGATION_STATES.map((state) => `go-state-${state}`), + assertAfterStep: (index, element) => { + assertScenario(id, NAVIGATION_STATES[index]!, element) + if (!mounted) { + throw new Error('Link benchmark app was not mounted') + } + mounted.assertStateUpdates() + }, + }) + + return { + async setup() { + await test.before() + if (!container) { + throw new Error('Link benchmark container was not created') + } + anchors = [...container.querySelectorAll('a[data-perf-link]')] + }, + async batch() { + for (let index = 0; index < ticksPerIteration; index++) { + await test.tick() + } + await test.finishBatch() + }, + check() { + if (!container || !mounted) { + throw new Error('Link benchmark app was not mounted') + } + assertScenario(id, 0, container) + mounted.assertStateUpdates() + if (mounted.router.history.length !== 1) { + throw new Error('Link benchmark history must remain bounded') + } + const current = container.querySelectorAll('a[data-perf-link]') + if (anchors.some((anchor, index) => current[index] !== anchor)) { + throw new Error('Measured Links must stay mounted across navigations') + } + }, + teardown: test.after, + } +} + +export function createSsrScenario( + app: typeof SsrApp, + id: LinkCaseId, +): LinkScenario { + let html = '' + let state = 0 + function check() { + const dom = new JSDOM(html) + try { + assertScenario(id, state, dom.window.document) + } finally { + dom.window.close() + } + } + + return { + async setup() { + for (const nextState of NAVIGATION_STATES) { + state = nextState + html = await app.renderScenario(id, state, true) + check() + } + }, + async batch() { + for (const nextState of NAVIGATION_STATES) { + state = nextState + html = await app.renderScenario(id, state) + } + }, + check, + teardown() { + html = '' + }, + } +} diff --git a/benchmarks/client-nav/link-performance/src/workload.tsx b/benchmarks/client-nav/link-performance/src/workload.tsx index 85b7261d9c1..8315b85b8b3 100644 --- a/benchmarks/client-nav/link-performance/src/workload.tsx +++ b/benchmarks/client-nav/link-performance/src/workload.tsx @@ -299,6 +299,7 @@ function RootLayout() { { - let html = '' - let state = 0 - - function assertHtml() { - const dom = new JSDOM(html) + const scenario = createSsrScenario(app, id) + function finish() { try { - assertScenario(id, state, dom.window.document) + scenario.check() } finally { - dom.window.close() + scenario.teardown() } } + beforeEach(scenario.setup) + afterEach(finish) - async function prepare() { - for (const nextState of NAVIGATION_STATES) { - state = nextState - html = await app.renderScenario(id, state, true) - assertHtml() - } - } - - beforeEach(prepare) - afterEach(assertHtml) - - bench( - `SSR Links: ${id}`, - async () => { - for (const nextState of NAVIGATION_STATES) { - state = nextState - html = await app.renderScenario(id, state) - } - }, - { - ...benchOptions, - time: 3_000, - setup: prepare, - teardown: assertHtml, - }, - ) + bench(`SSR Links: ${id}`, scenario.batch, { + ...samplingOptions, + setup: scenario.setup, + teardown: finish, + }) }) } diff --git a/benchmarks/client-nav/link-performance/stable-runner.ts b/benchmarks/client-nav/link-performance/stable-runner.ts new file mode 100644 index 00000000000..d947a14f21d --- /dev/null +++ b/benchmarks/client-nav/link-performance/stable-runner.ts @@ -0,0 +1,274 @@ +import { fork } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' +import { LINK_CASES } from './cases' +import { classify, mean, summarizeRatios } from './statistics' +import type { LinkCaseId } from './cases' +import type { + BlockSample, + Mode, + Variant, + WorkerRequest, + WorkerResponse, +} from './worker-protocol' + +const projectRoot = fileURLToPath(new URL('.', import.meta.url)) +const workspaceRoot = resolve(projectRoot, '../../..') + +function startWorker() { + const child = fork( + fileURLToPath(new URL('./stable-worker.ts', import.meta.url)), + { + cwd: workspaceRoot, + env: { ...process.env, NODE_ENV: 'production' }, + execArgv: [ + '--import=@swc-node/register/esm-register', + '--random-seed=42', + '--hash-seed=42', + ], + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }, + ) + let stderr = '' + child.stderr?.on('data', (data: Buffer) => { + stderr = (stderr + data.toString()).slice(-4_096) + }) + + function request(message: WorkerRequest): Promise { + return new Promise((resolveResponse, reject) => { + const timeout = setTimeout(() => { + finish( + new Error(`Sampling worker timed out: ${message.kind}\n${stderr}`), + ) + }, 60_000) + const onExit = (code: number | null) => + finish(new Error(`Sampling worker exited (${code})\n${stderr}`)) + const onError = (error: Error) => finish(error) + const onMessage = (response: WorkerResponse) => { + if (response.kind === 'error') { + finish(new Error(response.message)) + } else { + finish(undefined, response) + } + } + function finish(error?: Error, response?: WorkerResponse) { + clearTimeout(timeout) + child.off('message', onMessage) + child.off('error', onError) + child.off('exit', onExit) + if (error) { + reject(error) + } else if (response) { + resolveResponse(response) + } + } + child.once('message', onMessage) + child.once('error', onError) + child.once('exit', onExit) + child.send(message, (error) => { + if (error) { + finish(error) + } + }) + }) + } + + return { + request, + kill: () => child.kill(), + } +} + +async function sampleReplica( + mode: Mode, + caseId: LinkCaseId, + baseline: string, + current: string, + replica: number, +) { + const worker = startWorker() + const samples: Array> = [[], []] + const ratios: Array<{ cpu: number; wall: number }> = [] + try { + const starts: Array = [] + // Balance module initialization and which variant finishes warming last. + const startupOrder: Array = replica % 2 ? [1, 0] : [0, 1] + for (const index of startupOrder) { + const ready = await worker.request({ + kind: 'init', + mode, + caseId, + bundle: index === 0 ? baseline : current, + variant: index, + }) + if (ready.kind !== 'ready') { + throw new Error('Expected a ready sampling worker') + } + starts[index] = ready.batchMs + } + const iterations = Math.max(1, Math.ceil(500 / Math.min(...starts))) + for (let round = 0; round < 2; round++) { + const block: Array> = [[], []] + const order: Array = + (round + replica) % 2 ? [1, 0, 0, 1] : [0, 1, 1, 0] + for (const index of order) { + const result = await worker.request({ + kind: 'measure', + iterations, + variant: index, + }) + if (result.kind !== 'sample') { + throw new Error('Expected a block measurement') + } + samples[index]!.push(result.sample) + block[index]!.push(result.sample) + } + ratios.push({ + cpu: + mean(block[1]!.map((s) => s.cpuMs)) / + mean(block[0]!.map((s) => s.cpuMs)), + wall: + mean(block[1]!.map((s) => s.wallMs)) / + mean(block[0]!.map((s) => s.wallMs)), + }) + } + const response = await worker.request({ kind: 'stop' }) + if (response.kind !== 'stopped') { + throw new Error('Expected successful post-measurement assertions') + } + return { + iterations, + baseline: samples[0]!, + current: samples[1]!, + cpuRatio: Math.exp(mean(ratios.map((r) => Math.log(r.cpu)))), + wallRatio: Math.exp(mean(ratios.map((r) => Math.log(r.wall)))), + } + } finally { + worker.kill() + } +} + +async function main() { + if (process.env.TSR_LINK_PERF !== '1') { + console.log('Link performance sampling is disabled; set TSR_LINK_PERF=1.') + return + } + const { values } = parseArgs({ + options: { + baseline: { type: 'string' }, + current: { type: 'string', default: resolve(projectRoot, 'dist') }, + mode: { type: 'string', default: 'all' }, + repeats: { type: 'string', default: '4' }, + outputJson: { type: 'string' }, + testNamePattern: { type: 'string', short: 't' }, + }, + }) + if (!values.baseline || !values.outputJson) { + throw new Error( + 'Specify --baseline and --outputJson ', + ) + } + const repeats = Number(values.repeats) + if (!Number.isInteger(repeats) || repeats < 3 || repeats > 11) { + throw new Error('--repeats must be an integer between 3 and 11') + } + if (!['all', 'client', 'ssr'].includes(values.mode)) { + throw new Error('--mode must be all, client, or ssr') + } + const modes: Array = + values.mode === 'all' + ? ['client', 'ssr'] + : values.mode === 'client' + ? ['client'] + : ['ssr'] + const pattern = values.testNamePattern + ? new RegExp(values.testNamePattern) + : undefined + const cases = LINK_CASES.filter( + (c) => !pattern || pattern.test(`${c.label} ${c.id}`), + ) + if (!cases.length) { + throw new Error('No Link workloads match the requested pattern') + } + const report: { + protocol: string + node: string + complete: boolean + results: Array<{ + mode: Mode + caseId: LinkCaseId + baselineSha256: string + currentSha256: string + replicas: Array>> + cpu: ReturnType + wall: ReturnType + verdict: ReturnType + }> + } = { + protocol: + 'fresh process per case/replica; shared React runtime; separately loaded router variants; 2 ABBA/BAAB rounds; ~500ms calibrated fixed-work blocks; >=2s/100 batches warmup; main-thread CPU and wall time; fixed V8 random/hash seeds', + node: process.version, + complete: false, + results: [], + } + const output = resolve(values.outputJson) + mkdirSync(dirname(output), { recursive: true }) + mkdirSync(resolve(projectRoot, 'dist'), { recursive: true }) + const staging = mkdtempSync(resolve(projectRoot, 'dist/comparison-')) + try { + for (const mode of modes) { + const baselineInput = resolve(values.baseline, mode, 'app.js') + const currentInput = resolve(values.current, mode, 'app.js') + // Both snapshots resolve React from the same package installation. + const baseline = resolve(staging, `baseline-${mode}.mjs`) + const current = resolve(staging, `current-${mode}.mjs`) + copyFileSync(baselineInput, baseline) + copyFileSync(currentInput, current) + const digest = (file: string) => + createHash('sha256').update(readFileSync(file)).digest('hex') + const baselineSha256 = digest(baseline) + const currentSha256 = digest(current) + for (const { id } of cases) { + const replicas = [] + for (let replica = 0; replica < repeats; replica++) { + replicas.push( + await sampleReplica(mode, id, baseline, current, replica), + ) + } + const cpu = summarizeRatios(replicas.map((r) => r.cpuRatio)) + const wall = summarizeRatios(replicas.map((r) => r.wallRatio)) + const verdict = classify(cpu, wall) + report.results.push({ + mode, + caseId: id, + baselineSha256, + currentSha256, + replicas, + cpu, + wall, + verdict, + }) + writeFileSync(output, JSON.stringify(report, null, 2)) + console.log( + `${mode} ${id}: CPU ${cpu.changePercent.toFixed(2)}% [${cpu.low95.toFixed(2)}, ${cpu.high95.toFixed(2)}], wall ${wall.changePercent.toFixed(2)}% [${wall.low95.toFixed(2)}, ${wall.high95.toFixed(2)}] ${verdict}`, + ) + } + } + report.complete = true + writeFileSync(output, JSON.stringify(report, null, 2)) + } finally { + rmSync(staging, { recursive: true }) + } +} + +await main() diff --git a/benchmarks/client-nav/link-performance/stable-worker.ts b/benchmarks/client-nav/link-performance/stable-worker.ts new file mode 100644 index 00000000000..d98679dc155 --- /dev/null +++ b/benchmarks/client-nav/link-performance/stable-worker.ts @@ -0,0 +1,112 @@ +import { pathToFileURL } from 'node:url' +import { createClientScenario, createSsrScenario } from './scenario' +import type { LinkScenario } from './scenario' +import type { WorkerRequest, WorkerResponse } from './worker-protocol' +import type * as ClientApp from './src/client' +import type * as SsrApp from './src/ssr' + +const scenarios: Array = [] +const creationOrder: Array = [] +let closeWindow = () => {} + +function reply(response: WorkerResponse) { + if (!process.send) { + throw new Error('The Link sampling worker requires an IPC parent') + } + process.send(response) +} + +async function handle(request: WorkerRequest) { + if (request.kind === 'init') { + if (typeof process.threadCpuUsage !== 'function') { + throw new Error( + 'Stable sampling requires Node.js with process.threadCpuUsage', + ) + } + if (scenarios[request.variant]) { + throw new Error('A worker must only initialize each variant once') + } + let scenario: LinkScenario + const bundleUrl = pathToFileURL(request.bundle) + bundleUrl.searchParams.set('linkPerfVariant', String(request.variant)) + if (request.mode === 'client') { + const { window } = await import('../jsdom') + closeWindow = () => window.close() + const app: typeof ClientApp = await import(bundleUrl.href) + if (app.serverEnvironment !== false) { + throw new Error('Expected a production client bundle') + } + scenario = createClientScenario(app, request.caseId) + } else { + const app: typeof SsrApp = await import(bundleUrl.href) + if (app.serverEnvironment !== true) { + throw new Error('Expected a production SSR bundle') + } + scenario = createSsrScenario(app, request.caseId) + } + scenarios[request.variant] = scenario + creationOrder.push(scenario) + await scenario.setup() + const start = performance.now() + let iterations = 0 + do { + await scenario.batch() + iterations++ + } while (iterations < 100 || performance.now() - start < 2_000) + reply({ + kind: 'ready', + batchMs: (performance.now() - start) / iterations, + }) + return + } + if (request.kind === 'measure') { + const scenario = scenarios[request.variant] + if (!scenario) { + throw new Error('The sampling worker has not been initialized') + } + if (!Number.isInteger(request.iterations) || request.iterations < 1) { + throw new Error('Expected a positive batch count') + } + const cpuStart = process.threadCpuUsage() + const processStart = process.cpuUsage() + const start = performance.now() + for (let index = 0; index < request.iterations; index++) { + await scenario.batch() + } + const wallMs = performance.now() - start + const cpu = process.threadCpuUsage(cpuStart) + const processCpu = process.cpuUsage(processStart) + reply({ + kind: 'sample', + sample: { + iterations: request.iterations, + wallMs: wallMs / request.iterations, + cpuMs: (cpu.user + cpu.system) / 1_000 / request.iterations, + processCpuMs: + (processCpu.user + processCpu.system) / 1_000 / request.iterations, + }, + }) + return + } + try { + for (const scenario of creationOrder) { + scenario.check() + } + } finally { + for (const scenario of creationOrder.reverse()) { + scenario.teardown() + } + closeWindow() + } + reply({ kind: 'stopped' }) +} + +process.on('message', (request: WorkerRequest) => { + void handle(request).catch((error: unknown) => { + reply({ + kind: 'error', + message: + error instanceof Error ? error.stack || error.message : String(error), + }) + }) +}) diff --git a/benchmarks/client-nav/link-performance/statistics.test.ts b/benchmarks/client-nav/link-performance/statistics.test.ts new file mode 100644 index 00000000000..8f5f37413b1 --- /dev/null +++ b/benchmarks/client-nav/link-performance/statistics.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest' +import { classify, mean, summarizeRatios } from './statistics' + +test('summarizes multiplicative paired changes across process replicas', () => { + const result = summarizeRatios([0.9, 0.9, 0.9, 0.9]) + expect(result.changePercent).toBeCloseTo(-10) + expect(result.low95).toBeCloseTo(-10) + expect(result.high95).toBeCloseTo(-10) + expect(result.replicas).toBe(4) +}) + +test('does not turn opposite noisy runs into a conclusive result', () => { + const result = summarizeRatios([0.9, 1.1, 0.95, 1.05]) + expect(result.low95).toBeLessThan(0) + expect(result.high95).toBeGreaterThan(0) + expect(classify(result, result)).toBe('inconclusive') +}) + +test('requires CPU and wall time to corroborate a direction', () => { + const slower = summarizeRatios([1.08, 1.09, 1.08, 1.09]) + const faster = summarizeRatios([0.9, 0.91, 0.9, 0.91]) + expect(classify(slower, slower)).toBe('slower') + expect(classify(faster, faster)).toBe('faster') + expect(classify(slower, faster)).toBe('inconclusive') + expect(classify(summarizeRatios([1, 1, 1]), summarizeRatios([1, 1, 1]))).toBe( + 'within-2%', + ) +}) + +test('rejects incomplete or invalid sampling data', () => { + expect(() => summarizeRatios([1])).toThrow() + expect(() => summarizeRatios([0, 1])).toThrow() + expect(() => summarizeRatios([NaN, 1])).toThrow() + expect(() => mean([])).toThrow() + expect(() => mean([Infinity])).toThrow() +}) diff --git a/benchmarks/client-nav/link-performance/statistics.ts b/benchmarks/client-nav/link-performance/statistics.ts new file mode 100644 index 00000000000..469bdaf65aa --- /dev/null +++ b/benchmarks/client-nav/link-performance/statistics.ts @@ -0,0 +1,57 @@ +const critical95 = [ + 12.7062, 4.3027, 3.1824, 2.7764, 2.5706, 2.4469, 2.3646, 2.306, 2.2622, + 2.2281, +] + +export function mean(values: ReadonlyArray) { + if (!values.length || values.some((value) => !Number.isFinite(value))) { + throw new Error('Expected finite, nonempty samples') + } + return values.reduce((sum, value) => sum + value, 0) / values.length +} + +export function summarizeRatios(ratios: ReadonlyArray) { + if ( + ratios.length < 2 || + ratios.length > 11 || + ratios.some((value) => value <= 0) + ) { + throw new Error('Expected 2-11 independent positive process-replica ratios') + } + const logs = ratios.map(Math.log) + const center = mean(logs) + const variance = + logs.reduce((sum, value) => sum + (value - center) ** 2, 0) / + (logs.length - 1) + // Replicas, not the many correlated batches inside a worker, are the samples. + const margin = + critical95[logs.length - 2]! * Math.sqrt(variance / logs.length) + const percent = (value: number) => (Math.exp(value) - 1) * 100 + return { + changePercent: percent(center), + low95: percent(center - margin), + high95: percent(center + margin), + replicas: ratios.length, + } +} + +export function classify( + cpu: ReturnType, + wall: ReturnType, +) { + if (cpu.low95 > 0 && wall.low95 > 0) { + return 'slower' + } + if (cpu.high95 < 0 && wall.high95 < 0) { + return 'faster' + } + if ( + cpu.low95 >= -2 && + cpu.high95 <= 2 && + wall.low95 >= -2 && + wall.high95 <= 2 + ) { + return 'within-2%' + } + return 'inconclusive' +} diff --git a/benchmarks/client-nav/link-performance/vitest.config.ts b/benchmarks/client-nav/link-performance/vitest.config.ts index a9b4cf69729..f8454c819dd 100644 --- a/benchmarks/client-nav/link-performance/vitest.config.ts +++ b/benchmarks/client-nav/link-performance/vitest.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ test: { watch: false, environment: 'node', - include: ['config.test.ts'], + include: ['*.test.ts'], }, }) diff --git a/benchmarks/client-nav/link-performance/worker-protocol.ts b/benchmarks/client-nav/link-performance/worker-protocol.ts new file mode 100644 index 00000000000..474858a529e --- /dev/null +++ b/benchmarks/client-nav/link-performance/worker-protocol.ts @@ -0,0 +1,28 @@ +import type { LinkCaseId } from './cases' + +export type Mode = 'client' | 'ssr' +export type Variant = 0 | 1 + +export type WorkerRequest = + | { + kind: 'init' + mode: Mode + caseId: LinkCaseId + bundle: string + variant: Variant + } + | { kind: 'measure'; iterations: number; variant: Variant } + | { kind: 'stop' } + +export interface BlockSample { + wallMs: number + cpuMs: number + processCpuMs: number + iterations: number +} + +export type WorkerResponse = + | { kind: 'ready'; batchMs: number } + | { kind: 'sample'; sample: BlockSample } + | { kind: 'stopped' } + | { kind: 'error'; message: string } From 5e89194f4ea5f8dc641320a6bb42442d26e17d51 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:39:52 +0200 Subject: [PATCH 09/19] perf(router): compact Solid and Vue Link state props Port the React Link bundle-size reductions to Solid and Vue: share exact/fuzzy pathname normalization, resolve only the selected active/inactive props, concatenate classes without temporary arrays, and avoid style allocation when neither source supplies styles. Vue forwards and visits one selected props object in both client and SSR paths. Keep framework-specific behavior: Solid retains its default-styling fast path and base href/handler precedence; Vue retains state-prop overrides and zero-argument callbacks. Both keep style snapshots so mutable Solid stores and Vue proxies remain reactive, including additions to empty style objects. No public API or unrelated production code changes. Official gzip measurements versus d4cc307869: - Solid minimal: 34017 -> 33973 (-44 bytes). - Solid full: 38973 -> 38930 (-43 bytes). - Vue minimal: 50731 -> 50646 (-85 bytes). - Vue full: 56484 -> 56395 (-89 bytes). All nine Solid/Vue Router/Start fixtures shrink by 26-104 gzip bytes; all nine React fixtures remain byte-identical. Independent hunk attribution: active pathname checks save 36/41 Solid and 37/34 Vue bytes. State-prop changes alone save 0/2 Solid and 46/53 Vue bytes. Combined gzip deltas are not additive. Existing 200-Link/eight-navigation workloads were compared with frozen parent, props-only, and final bundles. Standalone wall-time runs were too variable for a speed claim. Interleaved fixed-seed checks, repeated with reversed module order, showed effectively flat rendering cost: Solid mean CPU -1.49% / +0.33%, Vue -0.23% / -0.29%; corresponding wall changes -1.81% / +0.50% and -0.12% / -0.33%. No incremental rendering speedup is claimed. Raw results and experiments remain in uncommitted LOG.md and session artifacts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/six-adults-open.md | 6 + packages/solid-router/src/link.tsx | 67 +++---- .../solid-router/tests/link-style.test.tsx | 166 ++++++++++++++++++ .../tests/server/link-style.test.tsx | 75 ++++++++ packages/vue-router/src/link.tsx | 136 +++++--------- .../vue-router/tests/link-style-ssr.test.tsx | 78 ++++++++ packages/vue-router/tests/link-style.test.tsx | 162 +++++++++++++++++ 7 files changed, 561 insertions(+), 129 deletions(-) create mode 100644 .changeset/six-adults-open.md create mode 100644 packages/solid-router/tests/link-style.test.tsx create mode 100644 packages/solid-router/tests/server/link-style.test.tsx create mode 100644 packages/vue-router/tests/link-style-ssr.test.tsx create mode 100644 packages/vue-router/tests/link-style.test.tsx diff --git a/.changeset/six-adults-open.md b/.changeset/six-adults-open.md new file mode 100644 index 00000000000..f5486a5ff9b --- /dev/null +++ b/.changeset/six-adults-open.md @@ -0,0 +1,6 @@ +--- +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Reduce Link bundle size by sharing exact/fuzzy active-path checks and resolving only the selected active or inactive props. Avoid unnecessary class/style allocations while preserving reactive style updates, server rendering, and each framework's prop-override behavior. diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 1f70fa8af93..05f8b3113e9 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -4,7 +4,6 @@ import { mergeRefs } from '@solid-primitives/refs' import { deepEqual, - exactPathTest, functionalUpdate, hasKeys, isAbsoluteUrl, @@ -204,29 +203,15 @@ export function useLinkProps< const current = currentLocation() const nextLocation = next() - if (activeOptions?.exact) { - const testExact = exactPathTest( - current.pathname, - nextLocation.pathname, - router.basepath, - ) - if (!testExact) { - return false - } - } else { - const currentPath = removeTrailingSlash(current.pathname, router.basepath) - const nextPath = removeTrailingSlash( - nextLocation.pathname, - router.basepath, - ) - - const pathIsFuzzyEqual = - currentPath.startsWith(nextPath) && - (currentPath.length === nextPath.length || - currentPath[nextPath.length] === '/') - if (!pathIsFuzzyEqual) { - return false - } + const currentPath = removeTrailingSlash(current.pathname, router.basepath) + const nextPath = removeTrailingSlash(nextLocation.pathname, router.basepath) + if ( + currentPath !== nextPath && + (activeOptions?.exact || + !currentPath.startsWith(nextPath) || + currentPath[nextPath.length] !== '/') + ) { + return false } if (activeOptions?.includeSearch ?? true) { @@ -431,26 +416,26 @@ export function useLinkProps< } } - 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 stateProps: ResolvedLinkStateProps = + functionalUpdate(active ? local.activeProps : local.inactiveProps, {}) ?? + EMPTY_OBJECT + const baseStyle = local.style + const stateStyle = stateProps.style + // Snapshot reactive style properties so in-place updates remain observable. + const style = + baseStyle || stateStyle ? { ...baseStyle, ...stateStyle } : undefined + const baseClass = local.class + const stateClass = stateProps.class + const className = baseClass + ? stateClass + ? `${baseClass} ${stateClass}` + : baseClass + : stateClass return { - ...activeProps, - ...inactiveProps, + ...stateProps, ...base, - ...(hasKeys(style) ? { style } : undefined), + ...(style && hasKeys(style) ? { style } : undefined), ...(className ? { class: className } : undefined), ...(active && STATIC_ACTIVE_ATTRIBUTES), } as ResolvedLinkStateProps diff --git a/packages/solid-router/tests/link-style.test.tsx b/packages/solid-router/tests/link-style.test.tsx new file mode 100644 index 00000000000..58ecb64e4f3 --- /dev/null +++ b/packages/solid-router/tests/link-style.test.tsx @@ -0,0 +1,166 @@ +import * as Solid from 'solid-js' +import { createStore } from 'solid-js/store' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const disposers: Array<() => void> = [] +afterEach(() => { + cleanup() + for (const dispose of disposers.splice(0)) { + dispose() + } +}) + +function renderLinks(Links: Solid.Component) { + const history = createMemoryHistory({ initialEntries: ['/target'] }) + disposers.push(history.destroy) + const root = createRootRoute({ + component: () => ( + <> + + + + ), + }) + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/' }), + createRoute({ getParentRoute: () => root, path: '/target' }), + ]), + history, + scrollRestoration: false, + defaultPreload: false, + }) + render(() => ) + return router +} + +test('tracks additions to an initially empty state-only style store', async () => { + const [style, setStyle] = createStore({}) + renderLinks(() => ( + ({ style })}> + Reactive + + )) + const link = await screen.findByRole('link', { name: 'Reactive' }) + expect(link.style.color).toBe('') + setStyle('color', 'red') + await waitFor(() => expect(link.style.color).toBe('red')) + setStyle('color', undefined) + await waitFor(() => expect(link.style.color).toBe('')) +}) + +test('merges only the selected state and tracks reactive style properties', async () => { + const [base, setBase] = createStore({ + color: 'red', + 'margin-top': '2px', + }) + const [stateStyle, setStateStyle] = createStore({ + 'background-color': 'white', + }) + const [stateClass, setStateClass] = Solid.createSignal('selected') + const active = vi.fn(() => ({ + class: stateClass(), + style: stateStyle, + title: 'selected', + href: '/state-href', + })) + const inactive = vi.fn(() => ({ + class: 'idle', + style: { color: 'gray' }, + title: 'idle', + })) + const router = renderLinks(() => ( + + Styled + + )) + const link = await screen.findByRole('link', { name: 'Styled' }) + expect(link).toHaveClass('base', 'selected') + expect(link.style).toMatchObject({ + color: 'red', + marginTop: '2px', + backgroundColor: 'white', + }) + expect(link).toHaveAttribute('href', '/target') + expect(inactive).not.toHaveBeenCalled() + + setBase('color', 'blue') + setStateStyle('background-color', 'black') + setStateClass('updated') + await waitFor(() => { + expect(link).toHaveClass('base', 'updated') + expect(link.style).toMatchObject({ + color: 'blue', + backgroundColor: 'black', + }) + }) + + await router.navigate({ to: '/' }) + await waitFor(() => { + expect(link).toHaveClass('base', 'idle') + expect(link).toHaveAttribute('title', 'idle') + expect(link.style).toMatchObject({ color: 'gray', marginTop: '2px' }) + expect(link.style.backgroundColor).toBe('') + }) + active.mockClear() + inactive.mockClear() + setBase('margin-top', '3px') + await waitFor(() => expect(link.style).toMatchObject({ marginTop: '3px' })) + expect(active).not.toHaveBeenCalled() + expect(inactive).toHaveBeenCalled() + + await router.navigate({ to: '/target' }) + await waitFor(() => { + expect(link).toHaveClass('base', 'updated') + expect(link.style).toMatchObject({ + color: 'blue', + backgroundColor: 'black', + }) + }) +}) + +test('switches between default and custom state props without stale attributes', async () => { + const [custom, setCustom] = Solid.createSignal(false) + const router = renderLinks(() => ( + + Defaults + + )) + const link = await screen.findByRole('link', { name: 'Defaults' }) + expect(link).toHaveClass('active') + setCustom(true) + await waitFor(() => { + expect(link).not.toHaveClass('active') + expect(link).toHaveAttribute('title', 'custom') + expect(link.style).toMatchObject({ color: 'red' }) + }) + setCustom(false) + await waitFor(() => { + expect(link).toHaveClass('active') + expect(link).not.toHaveAttribute('title') + expect(link.style.color).toBe('') + }) + await router.navigate({ to: '/' }) + await waitFor(() => expect(link).not.toHaveClass('active')) +}) diff --git a/packages/solid-router/tests/server/link-style.test.tsx b/packages/solid-router/tests/server/link-style.test.tsx new file mode 100644 index 00000000000..05a470d4f3c --- /dev/null +++ b/packages/solid-router/tests/server/link-style.test.tsx @@ -0,0 +1,75 @@ +import { JSDOM } from 'jsdom' +import { expect, test, vi } from 'vitest' +import { Link, createRootRoute, createRoute, createRouter } from '../../src' +import { + RouterServer, + createRequestHandler, + renderRouterToString, +} from '../../src/ssr/server' + +test('preserves selected state props and styling during SSR', async () => { + const active = vi.fn(() => ({ + class: 'selected', + style: { color: 'blue' }, + title: 'selected', + })) + const unused = vi.fn(() => ({ class: 'unused' })) + const root = createRootRoute({ + component: () => ( + <> + + Active + + + Inactive + + Default + + Empty + + + ), + }) + const routeTree = root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/' }), + createRoute({ getParentRoute: () => root, path: '/other' }), + ]) + const handler = createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => createRouter({ routeTree, isServer: true }), + }) + const response = await handler(({ router, responseHeaders }) => + renderRouterToString({ + router, + responseHeaders, + children: () => , + }), + ) + const dom = new JSDOM(await response.text()) + try { + const links = dom.window.document.querySelectorAll('a') + expect(links).toHaveLength(4) + expect([...links[0]!.classList]).toEqual(['base', 'selected']) + expect(links[0]!.style.color).toBe('blue') + expect(links[0]!.style.marginTop).toBe('2px') + expect(links[0]!.getAttribute('title')).toBe('selected') + expect([...links[1]!.classList]).toEqual(['idle']) + expect(links[1]!.getAttribute('data-state')).toBe('idle') + expect([...links[2]!.classList]).toEqual(['active']) + expect([...links[3]!.classList]).toEqual([]) + expect(active).toHaveBeenCalled() + expect(unused).not.toHaveBeenCalled() + } finally { + dom.window.close() + } +}) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 4c2a04e4894..38e5db9bb67 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -1,7 +1,6 @@ import * as Vue from 'vue' import { deepEqual, - exactPathTest, hasKeys, isAbsoluteUrl, isDangerousProtocol, @@ -142,19 +141,14 @@ function useLinkPropsImpl( router, ) - const { - resolvedActiveProps, - resolvedInactiveProps, - resolvedClassName, - resolvedStyle, - } = resolveStyleProps(options, isActive) + const { resolvedProps, resolvedClassName, resolvedStyle } = + resolveStyleProps(options, isActive) const result = combineResultProps({ href, options, isActive, - resolvedActiveProps, - resolvedInactiveProps, + resolvedProps, resolvedClassName, resolvedStyle, }) @@ -411,20 +405,15 @@ function useLinkPropsImpl( return getExternalLinkProps(options, router, ref, staticEventHandlers) } - const { - resolvedActiveProps, - resolvedInactiveProps, - resolvedClassName, - resolvedStyle, - } = resolvedStyleProps.value + const { resolvedProps, resolvedClassName, resolvedStyle } = + resolvedStyleProps.value return combineResultProps({ href: href.value, options, ref, staticEventHandlers, isActive: isActive.value, - resolvedActiveProps, - resolvedInactiveProps, + resolvedProps, resolvedClassName, resolvedStyle, }) @@ -435,58 +424,47 @@ function useLinkPropsImpl( } function resolveStyleProps(options: AnyLinkPropsOptions, isActive: boolean) { - const activeProps = options.activeProps || (() => ({ class: 'active' })) - const resolvedActiveProps: StyledProps = (isActive - ? typeof activeProps === 'function' - ? activeProps() - : activeProps - : {}) || { class: undefined, style: undefined } - - const inactiveProps = options.inactiveProps || (() => ({})) - - const resolvedInactiveProps: StyledProps = (isActive - ? {} - : typeof inactiveProps === 'function' - ? inactiveProps() - : inactiveProps) || { class: undefined, style: undefined } - - const classes = [ - options.class, - resolvedActiveProps?.class, - resolvedInactiveProps?.class, - ].filter(Boolean) - const resolvedClassName = classes.length ? classes.join(' ') : undefined - - const result: Record = {} - - // Merge styles from all sources - if (options.style) { - Object.assign(result, options.style) - } - - if (resolvedActiveProps?.style) { - Object.assign(result, resolvedActiveProps.style) - } - - if (resolvedInactiveProps?.style) { - Object.assign(result, resolvedInactiveProps.style) + const props = + (isActive ? options.activeProps : options.inactiveProps) || + (isActive ? STATIC_ACTIVE_PROPS : EMPTY_OBJECT) + const resolvedProps: StyledProps = + (typeof props === 'function' ? props() : props) || EMPTY_OBJECT + const baseClass = options.class + const stateClass = resolvedProps.class + const resolvedClassName = baseClass + ? stateClass + ? `${baseClass} ${stateClass}` + : `${baseClass}` + : stateClass + ? `${stateClass}` + : undefined + + const baseStyle = options.style + const stateStyle = resolvedProps.style + let resolvedStyle: Record | undefined + if (baseStyle || stateStyle) { + // Keep a snapshot of reactive styles rather than returning their proxy. + const style: Record = {} + Object.assign(style, baseStyle, stateStyle) + if (hasKeys(style)) { + resolvedStyle = style + } } - - const resolvedStyle = hasKeys(result) ? result : undefined return { - resolvedActiveProps, - resolvedInactiveProps, + resolvedProps, resolvedClassName, resolvedStyle, } } +const STATIC_ACTIVE_PROPS = { class: 'active' } +const EMPTY_OBJECT = {} + function combineResultProps({ href, options, isActive, - resolvedActiveProps, - resolvedInactiveProps, + resolvedProps, resolvedClassName, resolvedStyle, ref, @@ -496,8 +474,7 @@ function combineResultProps({ href: string | undefined options: AnyLinkPropsOptions isActive: boolean - resolvedActiveProps: StyledProps - resolvedInactiveProps: StyledProps + resolvedProps: StyledProps resolvedClassName?: string resolvedStyle?: Record ref?: Vue.VNodeRef | undefined @@ -530,15 +507,9 @@ function combineResultProps({ result['aria-current'] = 'page' } - for (const key of Object.keys(resolvedActiveProps)) { - if (key !== 'class' && key !== 'style') { - result[key] = resolvedActiveProps[key] - } - } - - for (const key of Object.keys(resolvedInactiveProps)) { + for (const key of Object.keys(resolvedProps)) { if (key !== 'class' && key !== 'style') { - result[key] = resolvedInactiveProps[key] + result[key] = resolvedProps[key] } } return result @@ -673,26 +644,15 @@ function getIsActive( activeOptions: LinkOptions['activeOptions'], router: AnyRouter, ) { - if (activeOptions?.exact) { - const testExact = exactPathTest( - loc.pathname, - nextLoc.pathname, - router.basepath, - ) - if (!testExact) { - return false - } - } else { - const currentPath = removeTrailingSlash(loc.pathname, router.basepath) - const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath) - - const pathIsFuzzyEqual = - currentPath.startsWith(nextPath) && - (currentPath.length === nextPath.length || - currentPath[nextPath.length] === '/') - if (!pathIsFuzzyEqual) { - return false - } + const currentPath = removeTrailingSlash(loc.pathname, router.basepath) + const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath) + if ( + currentPath !== nextPath && + (activeOptions?.exact || + !currentPath.startsWith(nextPath) || + currentPath[nextPath.length] !== '/') + ) { + return false } if (activeOptions?.includeSearch ?? true) { diff --git a/packages/vue-router/tests/link-style-ssr.test.tsx b/packages/vue-router/tests/link-style-ssr.test.tsx new file mode 100644 index 00000000000..a6a854f503f --- /dev/null +++ b/packages/vue-router/tests/link-style-ssr.test.tsx @@ -0,0 +1,78 @@ +import * as Vue from 'vue' +import { renderToString } from 'vue/server-renderer' +import { expect, test, vi } from 'vitest' +import { + Link, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +test('preserves selected state props and styling during SSR', async () => { + const active = vi.fn(() => ({ + class: 'selected', + style: { color: 'blue' }, + title: 'selected', + })) + const unused = vi.fn(() => ({ class: 'unused' })) + const root = createRootRoute({ + component: () => + Vue.h(Vue.Fragment, [ + Vue.h( + Link, + { + to: '/', + class: 'base', + style: { color: 'red', marginTop: '2px' }, + activeProps: active, + inactiveProps: unused, + }, + () => 'Active', + ), + Vue.h( + Link, + { + to: '/other', + activeProps: unused, + inactiveProps: { class: 'idle', 'data-state': 'idle' }, + }, + () => 'Inactive', + ), + Vue.h(Link, { to: '/' }, () => 'Default'), + Vue.h(Link, { to: '/', activeProps: {} }, () => 'Empty'), + ]), + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/' }), + createRoute({ getParentRoute: () => root, path: '/other' }), + ]), + history, + isServer: true, + }) + try { + await router.load() + const html = await renderToString( + Vue.createSSRApp(() => Vue.h(RouterProvider, { router })), + ) + const container = document.createElement('div') + container.innerHTML = html + const links = container.querySelectorAll('a') + expect(links).toHaveLength(4) + expect(links[0]!.className).toBe('base selected') + expect(links[0]!.style.color).toBe('blue') + expect(links[0]!.style.marginTop).toBe('2px') + expect(links[0]!.getAttribute('title')).toBe('selected') + expect(links[1]!.className).toBe('idle') + expect(links[1]!.getAttribute('data-state')).toBe('idle') + expect(links[2]!.className).toBe('active') + expect(links[3]!.className).toBe('') + expect(active).toHaveBeenCalledWith() + expect(unused).not.toHaveBeenCalled() + } finally { + history.destroy() + } +}) diff --git a/packages/vue-router/tests/link-style.test.tsx b/packages/vue-router/tests/link-style.test.tsx new file mode 100644 index 00000000000..12208003289 --- /dev/null +++ b/packages/vue-router/tests/link-style.test.tsx @@ -0,0 +1,162 @@ +import * as Vue from 'vue' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const disposers: Array<() => void> = [] +afterEach(() => { + cleanup() + for (const dispose of disposers.splice(0)) { + dispose() + } +}) + +function renderLinks(Links: Vue.Component) { + const history = createMemoryHistory({ initialEntries: ['/target'] }) + disposers.push(history.destroy) + const root = createRootRoute({ + component: () => Vue.h(Vue.Fragment, [Vue.h(Links), Vue.h(Outlet)]), + }) + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/' }), + createRoute({ getParentRoute: () => root, path: '/target' }), + ]), + history, + scrollRestoration: false, + defaultPreload: false, + }) + render(Vue.h(RouterProvider, { router })) + return router +} + +test('tracks additions to an initially empty state-only style proxy', async () => { + const style = Vue.reactive({}) + renderLinks(() => + Vue.h( + Link, + { to: '/target', activeProps: () => ({ style }) }, + () => 'Reactive', + ), + ) + const link = await screen.findByRole('link', { name: 'Reactive' }) + expect(link.style.color).toBe('') + style.color = 'red' + await waitFor(() => expect(link.style.color).toBe('red')) + delete style.color + await waitFor(() => expect(link.style.color).toBe('')) +}) + +test('merges only the selected state and tracks reactive style properties', async () => { + const base = Vue.reactive({ color: 'red', marginTop: '2px' }) + const stateStyle = Vue.reactive({ backgroundColor: 'white' }) + const stateClass = Vue.ref('selected') + const active = vi.fn(() => ({ + class: stateClass.value, + style: stateStyle, + title: 'selected', + href: '/state-href', + })) + const inactive = vi.fn(() => ({ + class: 'idle', + style: { color: 'gray' }, + title: 'idle', + })) + const router = renderLinks(() => + Vue.h( + Link, + { + to: '/target', + class: ['base', { decorated: true }], + style: base, + activeProps: active, + inactiveProps: inactive, + }, + () => 'Styled', + ), + ) + const link = await screen.findByRole('link', { name: 'Styled' }) + expect(link).toHaveClass('base', 'decorated', 'selected') + expect(link.style).toMatchObject({ + color: 'red', + marginTop: '2px', + backgroundColor: 'white', + }) + expect(link).toHaveAttribute('href', '/state-href') + expect(inactive).not.toHaveBeenCalled() + + base.color = 'blue' + stateStyle.backgroundColor = 'black' + stateClass.value = 'updated' + await waitFor(() => { + expect(link).toHaveClass('base', 'decorated', 'updated') + expect(link.style).toMatchObject({ + color: 'blue', + backgroundColor: 'black', + }) + }) + + await router.navigate({ to: '/' }) + await waitFor(() => { + expect(link).toHaveClass('base', 'idle') + expect(link).toHaveAttribute('title', 'idle') + expect(link).toHaveAttribute('href', '/target') + expect(link.style).toMatchObject({ color: 'gray', marginTop: '2px' }) + expect(link.style.backgroundColor).toBe('') + }) + active.mockClear() + inactive.mockClear() + base.marginTop = '3px' + await waitFor(() => expect(link.style).toMatchObject({ marginTop: '3px' })) + expect(active).not.toHaveBeenCalled() + expect(inactive).toHaveBeenCalled() + + await router.navigate({ to: '/target' }) + await waitFor(() => { + expect(link).toHaveClass('base', 'updated') + expect(link.style).toMatchObject({ + color: 'blue', + backgroundColor: 'black', + }) + }) +}) + +test('switches between default and custom state props without stale attributes', async () => { + const custom = Vue.ref(false) + const router = renderLinks(() => + Vue.h( + Link, + { + to: '/target', + activeProps: custom.value + ? { title: 'custom', style: { color: 'red' } } + : undefined, + }, + () => 'Defaults', + ), + ) + const link = await screen.findByRole('link', { name: 'Defaults' }) + expect(link).toHaveClass('active') + custom.value = true + await waitFor(() => { + expect(link).not.toHaveClass('active') + expect(link).toHaveAttribute('title', 'custom') + expect(link.style).toMatchObject({ color: 'red' }) + }) + custom.value = false + await waitFor(() => { + expect(link).toHaveClass('active') + expect(link).not.toHaveAttribute('title') + expect(link.style.color).toBe('') + }) + await router.navigate({ to: '/' }) + await waitFor(() => expect(link).not.toHaveClass('active')) +}) From ac6d16d7fc7f52d7f66e582e80d43066d2cb5d46 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:51:00 +0200 Subject: [PATCH 10/19] refactor(router-core): clarify segment interpolation Organize the generic interpolation loop into static, wildcard, and named-parameter behavior blocks. Keep one shared key lookup, one encoding step, and one prefix/value/suffix assembly. Name affix boundaries and keep optional/missing wildcard behavior local to its segment type. Replace the self-clearing onMissing callback with an optional metadata result object. The public interpolatePath wrapper reuses its existing result object; pathname-only callers still allocate no metadata. Preserve one parameter read, the reusable parser buffer, absent optional cache keys, canonical and legacy splat metadata, and public return values. Fast-path empty splat strings before the URL-safe regex so sharing the encoding step does not penalize omitted values. Final official gzip measurements versus 5017a72614: - React minimal: 85771 -> 85769 (-2 bytes). - React full: 89384 -> 89382 (-2 bytes). - All 18 fixtures range from -4 to +8 bytes; no unrelated source changes compensate for interpolation cost. Added mixed-type/affix metadata coverage and direct pathname-kernel benchmarks. Paired fixed-seed CPU measurements of the real parent/current modules show effectively flat required client pathname interpolation (+0.04%) and 0.37-4.14% lower time in the other sampled required/optional/splat/mixed/missing client/server combinations. These are focused kernel diagnostics, not a broad application speedup claim. Preserve the experiments, rejected larger variants, full fixture metrics, and raw benchmark output in uncommitted LOG.md and session artifacts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/lucky-candies-smoke.md | 5 + packages/router-core/src/path.ts | 92 +++++++++++-------- .../tests/path-interpolation.bench.ts | 43 ++++++++- packages/router-core/tests/path.test.ts | 53 +++++++++++ 4 files changed, 155 insertions(+), 38 deletions(-) create mode 100644 .changeset/lucky-candies-smoke.md diff --git a/.changeset/lucky-candies-smoke.md b/.changeset/lucky-candies-smoke.md new file mode 100644 index 00000000000..29b62a90b9f --- /dev/null +++ b/.changeset/lucky-candies-smoke.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Simplify path interpolation into ordered segment-type blocks with shared prefix/value/suffix assembly. Preserve one-pass parsing and optional metadata collection, and reuse the public result object instead of allocating and clearing a missing-parameter callback. diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index b469ef3f1b5..45239f177ae 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -240,7 +240,9 @@ function encodeParam( if (key === '_splat') { // Early return if value only contains URL-safe characters (performance optimization) - if (/^[a-zA-Z0-9\-._~!/]*$/.test(value)) return value + if (!value || /^[a-zA-Z0-9\-._~!/]*$/.test(value)) { + return value + } // the splat/catch-all routes shouldn't have the '/' encoded out // Use encodeURIComponent for each segment to properly encode spaces, // plus signs, and other special characters that encodeURI leaves unencoded @@ -263,20 +265,21 @@ export function interpolatePath( options: InterpolatePathOptions, ): InterPolatePathResult { const { path, params, decoder, server } = options - const usedParams: Record = Object.create(null) - let isMissingParams = false - const interpolatedPath = interpolatePathname( - path || '/', + const result: InterPolatePathResult = { + interpolatedPath: path || '/', + usedParams: Object.create(null), + isMissingParams: false, + } + result.interpolatedPath = interpolatePathname( + result.interpolatedPath, params, decoder, - usedParams, + result.usedParams, undefined, server, - () => { - isMissingParams = true - }, + result, ) - return { interpolatedPath, usedParams, isMissingParams } + return result } /** @@ -290,7 +293,7 @@ export function interpolatePathname( usedParams?: Record, keys?: Array, server?: boolean, - onMissing?: () => void, + metadata?: { isMissingParams: boolean }, ): string { if (!path.includes('$')) { return path @@ -329,9 +332,8 @@ export function interpolatePathname( const key = splat ? '_splat' : part.substring(1) const value = params[key] keys?.push(key) - if (onMissing && !(splat ? value : key in params)) { - onMissing() - onMissing = undefined + if (metadata && (splat ? !value : !(key in params))) { + metadata.isMissingParams = true } if (usedParams) { usedParams[key] = value @@ -376,34 +378,50 @@ export function interpolatePathname( continue } - const splat = kind === SEGMENT_TYPE_WILDCARD - const optional = kind === SEGMENT_TYPE_OPTIONAL_PARAM - const key = splat ? '_splat' : path.substring(segment[2], segment[3]) - const valueRaw = params[key] + const prefixEnd = segment[1] + const suffixStart = segment[4] + const key = + kind === SEGMENT_TYPE_WILDCARD + ? '_splat' + : path.substring(segment[2], segment[3]) + let paramValue = params[key] keys?.push(key) - if (onMissing && !(splat ? valueRaw : optional || key in params)) { - onMissing() - onMissing = undefined - } - if (optional && valueRaw == null) { - continue - } - if (usedParams) { - usedParams[key] = valueRaw - if (splat) { + + if (kind === SEGMENT_TYPE_WILDCARD) { + if (usedParams) { + usedParams[key] = paramValue // TODO: Deprecate * - usedParams['*'] = valueRaw + usedParams['*'] = paramValue + } + if (!paramValue) { + if (metadata) { + metadata.isMissingParams = true + } + // A missing wildcard keeps its affixes, but omits a bare segment. + if (prefixEnd === start && suffixStart === end) { + continue + } + paramValue = '' + } + } else { + // Named parameters: $id or {-$id}. + if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + if (paramValue == null) { + continue + } + } else if (metadata && !(key in params)) { + metadata.isMissingParams = true + } + if (usedParams) { + usedParams[key] = paramValue } } - const prefix = path.substring(start, segment[1]) - const suffix = path.substring(segment[4], end) - const emptySplat = splat && !valueRaw - if (emptySplat && !prefix && !suffix) { - continue - } - const value = emptySplat ? '' : encodeParam(key, valueRaw, decoder) - joined += '/' + prefix + value + suffix + joined += + '/' + + path.substring(start, prefixEnd) + + encodeParam(key, paramValue, decoder) + + path.substring(suffixStart, end) } if (path.endsWith('/')) { diff --git a/packages/router-core/tests/path-interpolation.bench.ts b/packages/router-core/tests/path-interpolation.bench.ts index baf1afd596e..6b23200b8b0 100644 --- a/packages/router-core/tests/path-interpolation.bench.ts +++ b/packages/router-core/tests/path-interpolation.bench.ts @@ -1,7 +1,11 @@ import { bench, describe, expect } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute } from '../src' -import { compileDecodeCharMap, interpolatePath } from '../src/path' +import { + compileDecodeCharMap, + interpolatePath, + interpolatePathname, +} from '../src/path' import { createTestRouter } from './routerTestUtils' type Options = Parameters[0] @@ -78,6 +82,17 @@ const scenarios: Array<{ name: string; inputs: Array }> = [ } }), }, + { + name: 'affixed mixed segments', + inputs: Array.from({ length: 200 }, (_, index) => ({ + path: '/root/prefix{$id}suffix/{-$language}/files/{$}.txt', + params: { + id: `item ${index % 40}`, + language: index % 2 ? 'en' : undefined, + _splat: index % 3 ? `docs/file ${index % 20}` : '', + }, + })), + }, ] const decoder = compileDecodeCharMap(['@', '+']) @@ -143,6 +158,32 @@ describe.each(scenarios)('$name', ({ inputs }) => { }, ) + bench( + 'pathname-only interpolation batch', + () => { + let length = 0 + for (const input of inputs) { + length += interpolatePathname( + input.path || '/', + input.params, + input.decoder, + undefined, + undefined, + input.server, + ).length + } + checksum = length + }, + { + time: 1500, + warmupTime: 500, + throws: true, + teardown: () => { + expect(checksum).toBe(expected) + }, + }, + ) + bench( 'uncached interpolation batch', () => { diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 92ab3682c09..8ee69bb4d74 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -3,6 +3,7 @@ import { compileDecodeCharMap, exactPathTest, interpolatePath, + interpolatePathname, removeTrailingSlash, resolvePath, trimPathLeft, @@ -187,6 +188,58 @@ describe.each([false, true])( ).toEqual({ _splat: 'docs/guide', '*': 'docs/guide' }) }) + it.each([ + { + params: { id: 'one two', _splat: 'docs/file name' }, + path: '/root/prefixone%20twosuffix/files/docs/file%20name.txt', + used: { + id: 'one two', + _splat: 'docs/file name', + '*': 'docs/file name', + }, + missing: false, + }, + { + params: { id: '', language: '', _splat: '' }, + path: '/root/prefixsuffix//files/.txt', + used: { id: '', language: '', _splat: '', '*': '' }, + missing: true, + }, + { + params: {}, + path: '/root/prefixundefinedsuffix/files/.txt', + used: { id: undefined, _splat: undefined, '*': undefined }, + missing: true, + }, + ])( + 'keeps mixed segment metadata in one pass for $params', + ({ params, path, used, missing }) => { + const template = '/root/prefix{$id}suffix/{-$language}/files/{$}.txt' + const usedParams: Record = Object.create(null) + const keys: Array = [] + const metadata = { isMissingParams: false } + expect( + interpolatePathname( + template, + params, + undefined, + usedParams, + keys, + server, + metadata, + ), + ).toBe(path) + expect(keys).toEqual(['id', 'language', '_splat']) + expect(usedParams).toEqual(used) + expect(metadata.isMissingParams).toBe(missing) + expect(interpolatePath({ path: template, params, server })).toEqual({ + interpolatedPath: path, + usedParams: used, + isMissingParams: missing, + }) + }, + ) + it.each([ { path: '/$first/$second', From 2d3fd1c91995b33e3e31bff4a6bd92c04cc61e4b Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:38:38 +0200 Subject: [PATCH 11/19] fix(router): honor Link state props and Vue class bindings Apply selected active/inactive props after ordinary base props in Solid and React, including React server output. Preserve the dedicated class concatenation and style merge handling. Add client/server regressions for supported refs and event handlers; do not widen the state-prop API to router navigation options. Keep Vue object and nested-array class values intact instead of interpolating them into strings. Compose base/state values with Vue-compatible arrays, retain omitted-class behavior, and clone props at VNode creation because Vue normalizes class values in place. This preserves cached bindings and later reactive class changes. Vue ordinary state-attribute precedence was already correct and is left unchanged. The regressions were run on the pre-fix implementations and failed for the reported behaviors, then passed after the fixes. Full framework unit/type/lint/package and Chromium coverage passes. Bundle impact remains small: React/Solid raw bytes unchanged; Vue Router minimal/full are 14/10 gzip bytes smaller. Detailed red/green evidence and measurements are kept in uncommitted LOG.md and session artifacts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/loud-weeks-boil.md | 7 + packages/react-router/src/link.tsx | 8 +- .../tests/link-state-props.test.tsx | 155 ++++++++++++++++-- packages/solid-router/src/link.tsx | 2 +- .../solid-router/tests/link-style.test.tsx | 50 +++++- .../tests/server/link-style.test.tsx | 69 +++++--- packages/vue-router/src/link.tsx | 34 ++-- .../vue-router/tests/link-style-ssr.test.tsx | 10 +- packages/vue-router/tests/link-style.test.tsx | 80 +++++++++ 9 files changed, 349 insertions(+), 66 deletions(-) create mode 100644 .changeset/loud-weeks-boil.md diff --git a/.changeset/loud-weeks-boil.md b/.changeset/loud-weeks-boil.md new file mode 100644 index 00000000000..9df1ba41b7b --- /dev/null +++ b/.changeset/loud-weeks-boil.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Allow active and inactive Link props to override base element props in React and Solid while preserving class/style merging. Keep React's `href`, `target`, and `disabled` values controlled by routing options. Preserve Vue object and nested-array class bindings, including reactive updates and server rendering, without mutating cached bindings during VNode normalization. diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 14b099c328b..69e810a8329 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -427,10 +427,11 @@ export function useLinkProps< return { ...propsSafeToSpread, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], ...resolvedActiveProps, ...resolvedInactiveProps, + // State props can override element props, but not routing options. href: hrefOption?.href, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], disabled: !!disabled, target, ...(resolvedStyle && { style: resolvedStyle }), @@ -673,8 +674,6 @@ export function useLinkProps< return { ...propsSafeToSpread, - ...resolvedProps, - href, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers(onClick, handleClick), onBlur: composeHandlers(onBlur, handleLeave), @@ -682,6 +681,9 @@ export function useLinkProps< onMouseEnter: composeHandlers(onMouseEnter, enqueuePreload), onMouseLeave: composeHandlers(onMouseLeave, handleLeave), onTouchStart: composeHandlers(onTouchStart, handleTouchStart), + ...resolvedProps, + // State props can override element props, but not routing options. + href, disabled: !!disabled, target, ...(style && { diff --git a/packages/react-router/tests/link-state-props.test.tsx b/packages/react-router/tests/link-state-props.test.tsx index 8c93c2ff7a2..570b2d0246f 100644 --- a/packages/react-router/tests/link-state-props.test.tsx +++ b/packages/react-router/tests/link-state-props.test.tsx @@ -1,31 +1,56 @@ -import React from 'react' -import { cleanup, render } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' +import * as React from 'react' +import { renderToString } from 'react-dom/server' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { Link, RouterContextProvider, + RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, + useLinkProps, } from '../src' -afterEach(cleanup) +const disposers: Array<() => void> = [] +afterEach(() => { + cleanup() + for (const dispose of disposers.splice(0)) { + dispose() + } +}) -test.each([false, true])( - 'active links preserve styling while disabled changes (masked=%s)', - async (masked) => { +test.each([ + { active: true, masked: false, server: false }, + { active: true, masked: true, server: false }, + { active: false, masked: false, server: false }, + { active: false, masked: true, server: false }, + { active: true, masked: false, server: true }, + { active: true, masked: true, server: true }, + { active: false, masked: false, server: true }, + { active: false, masked: true, server: true }, +])( + 'state props preserve routing while disabled changes (active: $active, masked: $masked, server: $server)', + async ({ active, masked, server }) => { const root = createRootRoute() const router = createRouter({ routeTree: root.addChildren([ createRoute({ getParentRoute: () => root, path: '/active' }), + createRoute({ getParentRoute: () => root, path: '/inactive' }), ]), - history: createMemoryHistory({ initialEntries: ['/active'] }), + history: createMemoryHistory({ + initialEntries: [active ? '/active' : '/inactive'], + }), + isServer: server, }) + disposers.push(router.history.destroy) await router.load() - const activeProps = { - className: 'active-state', + const stateProps = { + className: 'state-class', href: 'javascript:active()', + target: '_self', + disabled: false, } const content = (disabled: boolean) => ( @@ -33,18 +58,37 @@ test.each([false, true])( to="/active" mask={masked ? { to: '/masked' } : undefined} disabled={disabled} - activeProps={activeProps} + target="_blank" + activeProps={stateProps} + inactiveProps={stateProps} > Target ) - const view = render(content(false)) + const view = server ? undefined : render(content(false)) for (const disabled of [false, true, false]) { - view.rerender(content(disabled)) - const anchor = view.getByText('Target') - expect(anchor).toHaveClass('active-state') - expect(anchor).toHaveAttribute('aria-current', 'page') + let anchor: HTMLElement + if (view) { + view.rerender(content(disabled)) + anchor = view.getByText('Target') + } else { + const container = document.createElement('div') + container.innerHTML = renderToString(content(disabled)) + const link = container.querySelector('a') + expect(link).not.toBeNull() + if (!link) { + throw new Error('Expected the server-rendered Link') + } + anchor = link + } + expect(anchor).toHaveClass('state-class') + expect(anchor).toHaveAttribute('target', '_blank') + if (active) { + expect(anchor).toHaveAttribute('aria-current', 'page') + } else { + expect(anchor).not.toHaveAttribute('aria-current') + } if (disabled) { expect(anchor).not.toHaveAttribute('href') expect(anchor).toHaveAttribute('aria-disabled', 'true') @@ -55,3 +99,82 @@ test.each([false, true])( } }, ) + +test.each([ + { active: true, server: false }, + { active: false, server: false }, + { active: true, server: true }, + { active: false, server: true }, +])( + 'selected state props override base props (active: $active, server: $server)', + async ({ active, server }) => { + const baseClick = vi.fn() + const selectedClick = vi.fn((event: React.MouseEvent) => + event.preventDefault(), + ) + const unusedClick = vi.fn() + const stateRef = React.createRef() + let resolvedRef: React.Ref | undefined + const stateProps = { + ref: stateRef, + title: 'state title', + onClick: selectedClick, + className: 'state-class', + style: { color: 'blue' }, + } + const history = createMemoryHistory({ initialEntries: ['/target'] }) + disposers.push(history.destroy) + function TestLink() { + const props = useLinkProps({ + to: active ? '/target' : '/', + target: '_blank', + title: 'base title', + onClick: baseClick, + className: 'base', + style: { color: 'red', marginTop: 2 }, + activeProps: active ? stateProps : { onClick: unusedClick }, + inactiveProps: active ? { onClick: unusedClick } : stateProps, + preload: false, + }) + resolvedRef = props.ref + return Override + } + const root = createRootRoute({ component: TestLink }) + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/' }), + createRoute({ getParentRoute: () => root, path: '/target' }), + ]), + history, + isServer: server, + scrollRestoration: false, + }) + + let link: HTMLElement + if (server) { + await router.load() + const container = document.createElement('div') + container.innerHTML = renderToString() + const anchor = container.querySelector('a') + expect(anchor).not.toBeNull() + if (!anchor) { + throw new Error('Expected the server-rendered Link') + } + link = anchor + } else { + render() + link = await screen.findByRole('link', { name: 'Override' }) + } + + expect(resolvedRef).toBe(stateRef) + expect(link).toHaveAttribute('title', 'state title') + expect(link).toHaveClass('base', 'state-class') + expect(link.style).toMatchObject({ color: 'blue', marginTop: '2px' }) + if (!server) { + fireEvent.click(link) + expect(selectedClick).toHaveBeenCalledOnce() + expect(baseClick).not.toHaveBeenCalled() + expect(unusedClick).not.toHaveBeenCalled() + } + }, +) diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 05f8b3113e9..924442a15e5 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -433,8 +433,8 @@ export function useLinkProps< : stateClass return { - ...stateProps, ...base, + ...stateProps, ...(style && hasKeys(style) ? { style } : undefined), ...(className ? { class: className } : undefined), ...(active && STATIC_ACTIVE_ATTRIBUTES), diff --git a/packages/solid-router/tests/link-style.test.tsx b/packages/solid-router/tests/link-style.test.tsx index 58ecb64e4f3..bcab8d70aed 100644 --- a/packages/solid-router/tests/link-style.test.tsx +++ b/packages/solid-router/tests/link-style.test.tsx @@ -1,7 +1,13 @@ import * as Solid from 'solid-js' import { createStore } from 'solid-js/store' import { afterEach, expect, test, vi } from 'vitest' -import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@solidjs/testing-library' import { Link, Outlet, @@ -44,6 +50,47 @@ function renderLinks(Links: Solid.Component) { return router } +test.each([true, false])( + 'selected state props override base props (active: %s)', + async (active) => { + const baseClick = vi.fn() + const selectedClick = vi.fn((event: MouseEvent) => event.preventDefault()) + const unusedClick = vi.fn() + const selectedRef = vi.fn() + const stateProps = { + ref: selectedRef, + title: 'state title', + onClick: selectedClick, + class: 'state-class', + style: { color: 'blue' }, + } + renderLinks(() => ( + + Override + + )) + const link = await screen.findByRole('link', { name: 'Override' }) + expect(selectedRef).toHaveBeenCalledOnce() + expect(selectedRef.mock.calls[0]?.[0]).toBe(link) + expect(link).toHaveAttribute('title', 'state title') + expect(link).toHaveClass('base', 'state-class') + expect(link.style).toMatchObject({ color: 'blue', marginTop: '2px' }) + fireEvent.click(link) + expect(selectedClick).toHaveBeenCalledOnce() + expect(baseClick).not.toHaveBeenCalled() + expect(unusedClick).not.toHaveBeenCalled() + }, +) + test('tracks additions to an initially empty state-only style store', async () => { const [style, setStyle] = createStore({}) renderLinks(() => ( @@ -72,7 +119,6 @@ test('merges only the selected state and tracks reactive style properties', asyn class: stateClass(), style: stateStyle, title: 'selected', - href: '/state-href', })) const inactive = vi.fn(() => ({ class: 'idle', diff --git a/packages/solid-router/tests/server/link-style.test.tsx b/packages/solid-router/tests/server/link-style.test.tsx index 05a470d4f3c..c3a7fb99c4d 100644 --- a/packages/solid-router/tests/server/link-style.test.tsx +++ b/packages/solid-router/tests/server/link-style.test.tsx @@ -1,6 +1,12 @@ import { JSDOM } from 'jsdom' import { expect, test, vi } from 'vitest' -import { Link, createRootRoute, createRoute, createRouter } from '../../src' +import { + Link, + createRootRoute, + createRoute, + createRouter, + useLinkProps, +} from '../../src' import { RouterServer, createRequestHandler, @@ -8,37 +14,48 @@ import { } from '../../src/ssr/server' test('preserves selected state props and styling during SSR', async () => { + const activeRef = vi.fn() + const inactiveRef = vi.fn() + let resolvedActiveRef: unknown + let resolvedInactiveRef: unknown const active = vi.fn(() => ({ class: 'selected', style: { color: 'blue' }, title: 'selected', + ref: activeRef, })) const unused = vi.fn(() => ({ class: 'unused' })) const root = createRootRoute({ - component: () => ( - <> - - Active - - - Inactive - - Default - - Empty - - - ), + component: () => { + const activeProps = useLinkProps({ + to: '/', + class: 'base', + style: { color: 'red', 'margin-top': '2px' }, + activeProps: active, + inactiveProps: unused, + }) + const inactiveProps = useLinkProps({ + to: '/other', + activeProps: unused, + inactiveProps: { + class: 'idle', + 'data-state': 'idle', + ref: inactiveRef, + }, + }) + resolvedActiveRef = activeProps.ref + resolvedInactiveRef = inactiveProps.ref + return ( + <> + Active + Inactive + Default + + Empty + + + ) + }, }) const routeTree = root.addChildren([ createRoute({ getParentRoute: () => root, path: '/' }), @@ -63,8 +80,10 @@ test('preserves selected state props and styling during SSR', async () => { expect(links[0]!.style.color).toBe('blue') expect(links[0]!.style.marginTop).toBe('2px') expect(links[0]!.getAttribute('title')).toBe('selected') + expect(resolvedActiveRef).toBe(activeRef) expect([...links[1]!.classList]).toEqual(['idle']) expect(links[1]!.getAttribute('data-state')).toBe('idle') + expect(resolvedInactiveRef).toBe(inactiveRef) expect([...links[2]!.classList]).toEqual(['active']) expect([...links[3]!.classList]).toEqual([]) expect(active).toHaveBeenCalled() diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 38e5db9bb67..9a90ef449d3 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -141,15 +141,17 @@ function useLinkPropsImpl( router, ) - const { resolvedProps, resolvedClassName, resolvedStyle } = - resolveStyleProps(options, isActive) + const { resolvedProps, resolvedClass, resolvedStyle } = resolveStyleProps( + options, + isActive, + ) const result = combineResultProps({ href, options, isActive, resolvedProps, - resolvedClassName, + resolvedClass, resolvedStyle, }) @@ -405,7 +407,7 @@ function useLinkPropsImpl( return getExternalLinkProps(options, router, ref, staticEventHandlers) } - const { resolvedProps, resolvedClassName, resolvedStyle } = + const { resolvedProps, resolvedClass, resolvedStyle } = resolvedStyleProps.value return combineResultProps({ href: href.value, @@ -414,7 +416,7 @@ function useLinkPropsImpl( staticEventHandlers, isActive: isActive.value, resolvedProps, - resolvedClassName, + resolvedClass, resolvedStyle, }) }) @@ -431,12 +433,12 @@ function resolveStyleProps(options: AnyLinkPropsOptions, isActive: boolean) { (typeof props === 'function' ? props() : props) || EMPTY_OBJECT const baseClass = options.class const stateClass = resolvedProps.class - const resolvedClassName = baseClass + const resolvedClass = baseClass ? stateClass - ? `${baseClass} ${stateClass}` - : `${baseClass}` + ? [baseClass, stateClass] + : baseClass : stateClass - ? `${stateClass}` + ? stateClass : undefined const baseStyle = options.style @@ -452,7 +454,7 @@ function resolveStyleProps(options: AnyLinkPropsOptions, isActive: boolean) { } return { resolvedProps, - resolvedClassName, + resolvedClass, resolvedStyle, } } @@ -465,7 +467,7 @@ function combineResultProps({ options, isActive, resolvedProps, - resolvedClassName, + resolvedClass, resolvedStyle, ref, staticEventHandlers, @@ -475,7 +477,7 @@ function combineResultProps({ options: AnyLinkPropsOptions isActive: boolean resolvedProps: StyledProps - resolvedClassName?: string + resolvedClass?: StyledProps['class'] resolvedStyle?: Record ref?: Vue.VNodeRef | undefined staticEventHandlers?: LinkEventHandlers @@ -493,8 +495,8 @@ function combineResultProps({ result.style = resolvedStyle } - if (resolvedClassName) { - result.class = resolvedClassName + if (resolvedClass) { + result.class = resolvedClass } if (options.disabled) { @@ -898,8 +900,8 @@ const LinkImpl = Vue.defineComponent({ ) } - // Return the component with props and children - return Vue.h(Component, linkProps, slotContent) + // Vue normalizes class bindings in place; preserve the cached bindings. + return Vue.h(Component, { ...linkProps }, slotContent) } }, }) diff --git a/packages/vue-router/tests/link-style-ssr.test.tsx b/packages/vue-router/tests/link-style-ssr.test.tsx index a6a854f503f..79af1f21647 100644 --- a/packages/vue-router/tests/link-style-ssr.test.tsx +++ b/packages/vue-router/tests/link-style-ssr.test.tsx @@ -12,7 +12,7 @@ import { test('preserves selected state props and styling during SSR', async () => { const active = vi.fn(() => ({ - class: 'selected', + class: ['selected', [{ 'active-object': true, hidden: false }]], style: { color: 'blue' }, title: 'selected', })) @@ -36,7 +36,7 @@ test('preserves selected state props and styling during SSR', async () => { { to: '/other', activeProps: unused, - inactiveProps: { class: 'idle', 'data-state': 'idle' }, + inactiveProps: { class: { idle: true }, 'data-state': 'idle' }, }, () => 'Inactive', ), @@ -62,7 +62,11 @@ test('preserves selected state props and styling during SSR', async () => { container.innerHTML = html const links = container.querySelectorAll('a') expect(links).toHaveLength(4) - expect(links[0]!.className).toBe('base selected') + expect([...links[0]!.classList]).toEqual([ + 'base', + 'selected', + 'active-object', + ]) expect(links[0]!.style.color).toBe('blue') expect(links[0]!.style.marginTop).toBe('2px') expect(links[0]!.getAttribute('title')).toBe('selected') diff --git a/packages/vue-router/tests/link-style.test.tsx b/packages/vue-router/tests/link-style.test.tsx index 12208003289..865d7cdd402 100644 --- a/packages/vue-router/tests/link-style.test.tsx +++ b/packages/vue-router/tests/link-style.test.tsx @@ -9,6 +9,7 @@ import { createRootRoute, createRoute, createRouter, + useLinkProps, } from '../src' const disposers: Array<() => void> = [] @@ -38,6 +39,85 @@ function renderLinks(Links: Vue.Component) { return router } +test('preserves object and nested-array class bindings across state updates', async () => { + const activeClass = Vue.reactive({ selected: true, removed: false }) + const inactiveClass = Vue.reactive({ idle: true }) + const router = renderLinks(() => + Vue.h( + Link, + { + to: '/target', + class: ['base', { decorated: true }], + activeProps: () => ({ + class: ['active-state', [undefined, activeClass, ['nested']]], + }), + inactiveProps: { class: inactiveClass }, + }, + () => 'Class bindings', + ), + ) + const link = await screen.findByRole('link', { name: 'Class bindings' }) + expect([...link.classList]).toEqual([ + 'base', + 'decorated', + 'active-state', + 'selected', + 'nested', + ]) + activeClass.selected = false + activeClass.removed = true + await waitFor(() => { + expect(link).not.toHaveClass('selected') + expect(link).toHaveClass('removed', 'nested') + }) + await router.navigate({ to: '/' }) + await waitFor(() => { + expect([...link.classList]).toEqual(['base', 'decorated', 'idle']) + }) + inactiveClass.idle = false + await waitFor(() => + expect([...link.classList]).toEqual(['base', 'decorated']), + ) +}) + +test.each([false, true])( + 'useLinkProps retains class values rather than strings (with base: %s)', + async (withBase) => { + const baseClass = ['base', { decorated: true }] + const stateClass = { selected: true } + let binding: unknown + const HookLink = Vue.defineComponent({ + setup() { + const props = useLinkProps({ + to: '/target', + class: withBase ? baseClass : undefined, + activeProps: { class: stateClass }, + }) + return () => { + const resolved = Vue.unref(props) + binding = resolved.class + return Vue.h('a', { ...resolved }, 'Hook classes') + } + }, + }) + renderLinks(HookLink) + await screen.findByRole('link', { name: 'Hook classes' }) + if (withBase) { + expect(binding).toEqual([baseClass, stateClass]) + } else { + expect(binding).toBe(stateClass) + } + }, +) + +test('omits the class binding when neither source provides a class', async () => { + renderLinks(() => + Vue.h(Link, { to: '/target', activeProps: {} }, () => 'No classes'), + ) + const link = await screen.findByRole('link', { name: 'No classes' }) + expect(link).not.toHaveAttribute('class') +}) + test('tracks additions to an initially empty state-only style proxy', async () => { const style = Vue.reactive({}) renderLinks(() => From 13408a7abff5229177bed639dcf19cbf754ae7e5 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:52:40 +0200 Subject: [PATCH 12/19] fix(router-core): respect router mode in empty search shortcut Pass the effective RouterCore.isServer value from buildLocation into applySearchMiddleware instead of using the module-level flag for empty-search reuse. This fixes development server routers taking the client shortcut when the imported flag is undefined. Keep middleware composition, search inheritance, and subsequent structural sharing unchanged. Add a four-case client/server and middleware/no-middleware regression matrix. The development server/no-middleware case failed before the fix; all cases and the full core validation pass afterward. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/fancy-items-greet.md | 5 ++ packages/router-core/src/router.ts | 4 +- .../router-core/tests/build-location.test.ts | 52 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 .changeset/fancy-items-greet.md diff --git a/.changeset/fancy-items-greet.md b/.changeset/fancy-items-greet.md new file mode 100644 index 00000000000..26d1b87b027 --- /dev/null +++ b/.changeset/fancy-items-greet.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Respect the router instance's effective server mode when applying the empty-search middleware shortcut. Preserve client search reuse, inherited search identity, and middleware-chain behavior. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index fde94a44275..66a3a7f8329 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2117,6 +2117,7 @@ export class RouterCore< dest, destRoutes, opts._includeValidateSearch, + this.isServer, ) // Replace the equal deep @@ -2841,6 +2842,7 @@ function applySearchMiddleware( dest: BuildNextOptions, destRoutes: ReadonlyArray, includeValidateSearch: boolean | undefined, + server: boolean, ) { let middlewares: Array> | undefined @@ -2900,7 +2902,7 @@ function applySearchMiddleware( if (!middlewares?.length) { if (!dest.search) { - return !isServer && !hasKeys(search) ? search : {} + return !server && !hasKeys(search) ? search : {} } return dest.search === true ? search : functionalUpdate(dest.search, search) } diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index d860d5c395b..28573ade4e4 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { isServer as serverEnvironment } from '@tanstack/router-core/isServer' import * as pathUtils from '../src/path' +import * as utils from '../src/utils' import { BaseRootRoute, BaseRoute, @@ -303,6 +304,57 @@ describe('buildLocation - params function receives parsed params', () => { }) describe('buildLocation - search params', () => { + test.each([ + { isServer: false, withMiddleware: false }, + { isServer: true, withMiddleware: false }, + { isServer: false, withMiddleware: true }, + { isServer: true, withMiddleware: true }, + ])( + 'uses the router mode for empty-search reuse (server: $isServer, middleware: $withMiddleware)', + ({ isServer, withMiddleware }) => { + expect(serverEnvironment).toBeUndefined() + const rootRoute = new BaseRootRoute({ + search: { + middlewares: withMiddleware + ? [({ search, next }) => next(search)] + : [], + }, + }) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history, + isServer, + }) + const inherited = router.buildLocation({ to: '/', search: true }) + const share = vi.spyOn(utils, 'nullReplaceEqualDeep') + try { + const cleared = router.buildLocation({ to: '/' }) + const searchBeforeSharing = share.mock.calls.find( + ([previous]) => previous === inherited.search, + )?.[1] + expect(searchBeforeSharing).toEqual({}) + if (!isServer && !withMiddleware) { + expect(searchBeforeSharing).toBe(inherited.search) + } else { + expect(searchBeforeSharing).not.toBe(inherited.search) + } + expect(cleared.search).toBe(inherited.search) + expect(cleared.searchStr).toBe('') + expect(router.buildLocation({ to: '/', search: true }).search).toBe( + inherited.search, + ) + } finally { + share.mockRestore() + history.destroy() + } + }, + ) + test('preserves structural sharing when clearing an already empty search', () => { const rootRoute = new BaseRootRoute({}) const route = new BaseRoute({ From fbfc561b51ab1f53fa7eeb1b6fa77f1b4f0d25ca Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:22:18 +0200 Subject: [PATCH 13/19] fix(router-core): preserve search mode tree-shaking Prefer the compile-time isServer value in the empty-search shortcut and its buildLocation call, with the router instance mode as the development fallback. Keep middleware handling and search structural sharing unchanged. Regression tests fail with the previous implementation for both defined module modes. Actual React, Solid, and Vue minimal/full client bundles now remove the server guard and the this.isServer read. All 18 bundle fixtures lose 16 raw bytes; React minimal/full gzip changes from 85781/89392 to 85778/89386 bytes. No additional runtime timing gain is claimed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/shaggy-foxes-speak.md | 5 ++ packages/router-core/src/router.ts | 4 +- .../tests/search-middleware-mode.test.ts | 55 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 .changeset/shaggy-foxes-speak.md create mode 100644 packages/router-core/tests/search-middleware-mode.test.ts diff --git a/.changeset/shaggy-foxes-speak.md b/.changeset/shaggy-foxes-speak.md new file mode 100644 index 00000000000..06134c121b7 --- /dev/null +++ b/.changeset/shaggy-foxes-speak.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Preserve compile-time tree-shaking of the empty-search server-mode check while retaining the router instance mode as the development fallback. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 66a3a7f8329..9cb8767a7ee 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2117,7 +2117,7 @@ export class RouterCore< dest, destRoutes, opts._includeValidateSearch, - this.isServer, + isServer ?? this.isServer, ) // Replace the equal deep @@ -2902,7 +2902,7 @@ function applySearchMiddleware( if (!middlewares?.length) { if (!dest.search) { - return !server && !hasKeys(search) ? search : {} + return !(isServer ?? server) && !hasKeys(search) ? search : {} } return dest.search === true ? search : functionalUpdate(dest.search, search) } diff --git a/packages/router-core/tests/search-middleware-mode.test.ts b/packages/router-core/tests/search-middleware-mode.test.ts new file mode 100644 index 00000000000..2f7000556cb --- /dev/null +++ b/packages/router-core/tests/search-middleware-mode.test.ts @@ -0,0 +1,55 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import * as utils from '../src/utils' +import { createTestRouter } from './routerTestUtils' + +const environment = vi.hoisted(() => { + const value: { isServer: boolean | undefined } = { isServer: undefined } + return value +}) + +vi.mock('@tanstack/router-core/isServer', async (importOriginal) => ({ + ...(await importOriginal()), + get isServer() { + return environment.isServer + }, +})) + +afterEach(() => { + environment.isServer = undefined + vi.restoreAllMocks() +}) + +test.each([false, true])( + 'prefers the compile-time server mode (%s) for empty-search reuse', + (isServer) => { + environment.isServer = isServer + const root = new BaseRootRoute({}) + const route = new BaseRoute({ getParentRoute: () => root, path: '/' }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: root.addChildren([route]), + history, + isServer: !isServer, + }) + try { + const inherited = router.buildLocation({ to: '/', search: true }) + const share = vi.spyOn(utils, 'nullReplaceEqualDeep') + const cleared = router.buildLocation({ to: '/' }) + const searchBeforeSharing = share.mock.calls.find( + ([previous]) => previous === inherited.search, + )?.[1] + expect(searchBeforeSharing).toEqual({}) + if (isServer) { + expect(searchBeforeSharing).not.toBe(inherited.search) + } else { + expect(searchBeforeSharing).toBe(inherited.search) + } + expect(cleared.search).toEqual({}) + expect(cleared.searchStr).toBe('') + } finally { + history.destroy() + } + }, +) From e4ca6a921798ef4d25ac55cd1d4152d4b9909261 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:02:45 +0200 Subject: [PATCH 14/19] perf(router-core): compact navigation parameter resolution Declare the native null-prototype target once for non-inheriting parameter modes. Preserve updater isolation, native copy counts, and literal-parameter merge behavior. Group parameter resolution with search middleware without changing Link or path interpolation logic. Official current-branch bundles shrink in all 18 scenarios by 2-30 gzip bytes versus 9c7cab48c1. React Router minimal: 85778 -> 85748 (-30); full: 89386 -> 89384 (-2). A matched-main preview against cf166d160e measures React Router minimal at 85805 versus main 85821 (-16), and full at 89385 versus main 89398 (-13). Eight of nine matched-main React fixtures meet main; Start+Query remains +25 bytes. Small Solid overages are retained as agreed. Eight paired client/SSR cases with four independent replicas each detected no supported timing regression or improvement. Add coverage for fresh updater copies, null prototypes, inheritance, and clearing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/quick-otters-switch.md | 5 +++ packages/router-core/src/router.ts | 35 ++++++++------- .../router-core/tests/build-location.test.ts | 45 +++++++++++++++++++ 3 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 .changeset/quick-otters-switch.md diff --git a/.changeset/quick-otters-switch.md b/.changeset/quick-otters-switch.md new file mode 100644 index 00000000000..2207d8c0088 --- /dev/null +++ b/.changeset/quick-otters-switch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Reduce duplication in navigation parameter handling while preserving parameter inheritance, null-prototype dictionaries, and updater isolation. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 9cb8767a7ee..7e3a10c5d31 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2837,6 +2837,24 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { return {} } +function resolveNextParams( + spec: unknown, + base: Record, +): Record { + if (spec === undefined || spec === true) { + return base + } + const next = Object.create(null) + if (spec === false || spec === null) { + return next + } + if (typeof spec === 'function') { + Object.assign(next, base) + return Object.assign(next, spec(next)) + } + return Object.assign(next, base, spec) +} + function applySearchMiddleware( search: any, dest: BuildNextOptions, @@ -2969,23 +2987,6 @@ function findGlobalNotFoundRouteId( return rootRouteId } -function resolveNextParams( - spec: unknown, - base: Record, -): Record { - if (spec === false || spec === null) { - return Object.create(null) - } - if ((spec ?? true) === true) { - return base - } - if (typeof spec !== 'function') { - return Object.assign(Object.create(null), base, spec) - } - const next = Object.assign(Object.create(null), base) - return Object.assign(next, spec(next)) -} - function extractStrictParams( route: AnyRoute, accumulatedParams: Record, diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 28573ade4e4..81d073efb94 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -1793,6 +1793,51 @@ describe('buildLocation - basepath', () => { }) describe('buildLocation - params edge cases', () => { + test('isolates mutating params updaters while preserving inherit and clear modes', () => { + const rootRoute = new BaseRootRoute({}) + const userRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/users/{-$userId}', + }) + const history = createMemoryHistory({ initialEntries: ['/users/123'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([userRoute]), + history, + }) + const updater = vi.fn((params: { userId?: string }) => { + expect(Object.getPrototypeOf(params)).toBeNull() + expect(params).toEqual({ userId: '123' }) + params.userId = '456' + return params + }) + + try { + for (let i = 0; i < 2; i++) { + expect( + router.buildLocation({ + to: '/users/{-$userId}', + params: updater, + }).pathname, + ).toBe('/users/456') + } + expect(updater).toHaveBeenCalledTimes(2) + expect(updater.mock.calls[0]![0]).not.toBe(updater.mock.calls[1]![0]) + expect(router.buildLocation({ to: '/users/{-$userId}' }).pathname).toBe( + '/users/123', + ) + expect( + router.buildLocation({ to: '/users/{-$userId}', params: true }) + .pathname, + ).toBe('/users/123') + expect( + router.buildLocation({ to: '/users/{-$userId}', params: false }) + .pathname, + ).toBe('/users') + } finally { + history.destroy() + } + }) + test('copies static param getters once without mutating inherited params', () => { const rootRoute = new BaseRootRoute({}) const userRoute = new BaseRoute({ From 8a9f57e6382f4501a81a0d1e2c33d8367f927f2c Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:24:34 +0200 Subject: [PATCH 15/19] fix(solid-router): preserve computed Link routing props Restore the computed href, target, and disabled values after active/inactive state props. Keep state-prop ref/event overrides, class/style composition, and the default styling fast path unchanged for client and SSR rendering. Reproduce the history/rewrite href failures and extend existing state-prop regressions to cover conflicting routing fields. Full validation passes 922 client tests (one skipped), 33 server tests, type/lint/export checks, and 24 Chromium cases. The full bundle matrix adds 16/13 gzip bytes to Solid Router minimal/full and 13-17 bytes to Solid Start fixtures. React and Vue bundles are unchanged; no extra allocations or href recomputation are introduced. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/forty-loops-sing.md | 5 ++++ packages/solid-router/src/link.tsx | 4 +++ .../solid-router/tests/link-style.test.tsx | 8 +++++- .../tests/server/link-style.test.tsx | 27 ++++++++++++++----- 4 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 .changeset/forty-loops-sing.md diff --git a/.changeset/forty-loops-sing.md b/.changeset/forty-loops-sing.md new file mode 100644 index 00000000000..7200420363f --- /dev/null +++ b/.changeset/forty-loops-sing.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-router': patch +--- + +Keep Solid Link's computed `href`, `target`, and `disabled` values authoritative when active or inactive state props contain routing options, while preserving state-prop refs, event handlers, and class/style merging. diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 924442a15e5..e74801f3267 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -435,6 +435,10 @@ export function useLinkProps< return { ...base, ...stateProps, + // State props can override element props, but not routing options. + href: base.href, + disabled: base.disabled, + target: base.target, ...(style && hasKeys(style) ? { style } : undefined), ...(className ? { class: className } : undefined), ...(active && STATIC_ACTIVE_ATTRIBUTES), diff --git a/packages/solid-router/tests/link-style.test.tsx b/packages/solid-router/tests/link-style.test.tsx index bcab8d70aed..5f7aacdbb12 100644 --- a/packages/solid-router/tests/link-style.test.tsx +++ b/packages/solid-router/tests/link-style.test.tsx @@ -51,7 +51,7 @@ function renderLinks(Links: Solid.Component) { } test.each([true, false])( - 'selected state props override base props (active: %s)', + 'selected state props override element props but not routing options (active: %s)', async (active) => { const baseClick = vi.fn() const selectedClick = vi.fn((event: MouseEvent) => event.preventDefault()) @@ -63,6 +63,9 @@ test.each([true, false])( onClick: selectedClick, class: 'state-class', style: { color: 'blue' }, + href: '/state-href', + target: '_self', + disabled: true, } renderLinks(() => ( { +test('preserves routing options, selected state props, and styling during SSR', async () => { const activeRef = vi.fn() const inactiveRef = vi.fn() let resolvedActiveRef: unknown @@ -23,12 +23,24 @@ test('preserves selected state props and styling during SSR', async () => { style: { color: 'blue' }, title: 'selected', ref: activeRef, + href: '/state-active', + target: '_self', + disabled: true, })) + const inactive = { + class: 'idle', + 'data-state': 'idle', + ref: inactiveRef, + href: '/state-inactive', + target: '_self', + disabled: true, + } const unused = vi.fn(() => ({ class: 'unused' })) const root = createRootRoute({ component: () => { const activeProps = useLinkProps({ to: '/', + target: '_blank', class: 'base', style: { color: 'red', 'margin-top': '2px' }, activeProps: active, @@ -36,12 +48,9 @@ test('preserves selected state props and styling during SSR', async () => { }) const inactiveProps = useLinkProps({ to: '/other', + target: '_blank', activeProps: unused, - inactiveProps: { - class: 'idle', - 'data-state': 'idle', - ref: inactiveRef, - }, + inactiveProps: inactive, }) resolvedActiveRef = activeProps.ref resolvedInactiveRef = inactiveProps.ref @@ -80,9 +89,15 @@ test('preserves selected state props and styling during SSR', async () => { expect(links[0]!.style.color).toBe('blue') expect(links[0]!.style.marginTop).toBe('2px') expect(links[0]!.getAttribute('title')).toBe('selected') + expect(links[0]!.getAttribute('href')).toBe('/') + expect(links[0]!.getAttribute('target')).toBe('_blank') + expect(links[0]!.hasAttribute('disabled')).toBe(false) expect(resolvedActiveRef).toBe(activeRef) expect([...links[1]!.classList]).toEqual(['idle']) expect(links[1]!.getAttribute('data-state')).toBe('idle') + expect(links[1]!.getAttribute('href')).toBe('/other') + expect(links[1]!.getAttribute('target')).toBe('_blank') + expect(links[1]!.hasAttribute('disabled')).toBe(false) expect(resolvedInactiveRef).toBe(inactiveRef) expect([...links[2]!.classList]).toEqual(['active']) expect([...links[3]!.classList]).toEqual([]) From 29509b9b98efb6d5b3311254096da9a99e696dbe Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:33:01 +0200 Subject: [PATCH 16/19] refactor(router-core): consolidate path interpolation Remove the object-returning internal wrapper and keep one interpolatePath returning a pathname with optional metadata outputs. Migrate router, devtools, tests, and benchmarks while leaving parsing and cache limits unchanged. Devtools requests only missing-param status. Tests use direct expected paths and explicit metadata; cache benchmarks no longer carry obsolete factory compatibility dispatch. React Router minimal/full gzip are 85829/89413 bytes (+5/+4 from 8a9f57e638, still 14/4 below measured main). Full units/types/exports and 76 browser cases pass; eight paired client/SSR workloads show no supported timing change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/smart-corners-rhyme.md | 6 + packages/react-router/tests/link.bench.tsx | 7 +- packages/router-core/src/path.ts | 64 +--- packages/router-core/src/router.ts | 18 +- .../router-core/tests/build-location.test.ts | 2 +- .../tests/optional-path-params-clean.test.ts | 94 +++-- .../tests/optional-path-params.test.ts | 6 +- .../tests/path-interpolation.bench.ts | 65 +++- packages/router-core/tests/path.test.ts | 343 +++++++++++++----- packages/router-core/tests/routerTestUtils.ts | 22 +- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 23 +- .../tests/interpolation-navigation.test.ts | 90 +++++ packages/solid-router/tests/link.bench.tsx | 7 +- packages/vue-router/tests/link.bench.tsx | 5 +- 14 files changed, 483 insertions(+), 269 deletions(-) create mode 100644 .changeset/smart-corners-rhyme.md create mode 100644 packages/router-devtools-core/tests/interpolation-navigation.test.ts diff --git a/.changeset/smart-corners-rhyme.md b/.changeset/smart-corners-rhyme.md new file mode 100644 index 00000000000..87ae3205549 --- /dev/null +++ b/.changeset/smart-corners-rhyme.md @@ -0,0 +1,6 @@ +--- +'@tanstack/router-core': patch +'@tanstack/router-devtools-core': patch +--- + +Consolidate internal path interpolation into `interpolatePath`, returning a pathname directly and collecting metadata only when requested. Update router and devtools callers without changing route parsing or interpolation caching. diff --git a/packages/react-router/tests/link.bench.tsx b/packages/react-router/tests/link.bench.tsx index a04149ed615..56114e65d84 100644 --- a/packages/react-router/tests/link.bench.tsx +++ b/packages/react-router/tests/link.bench.tsx @@ -37,8 +37,11 @@ const InterpolatePathLink = ({ to, params, children, -}: React.PropsWithChildren) => { - const href = interpolatePath({ path: to, params }).interpolatedPath +}: React.PropsWithChildren<{ + to: string + params: Record +}>) => { + const href = interpolatePath(to, params) return {children} } diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 45239f177ae..f81f24aae59 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -208,31 +208,10 @@ export function compileDecodeCharMap( encoded.replace(regex, (match) => charMap.get(match) ?? match) } -interface InterpolatePathOptions { - path?: string - params: Record - /** - * A function that decodes a path parameter value. - * Obtained from `compileDecodeCharMap(pathParamsAllowedCharacters)`. - */ - decoder?: (encoded: string) => string - /** - * @internal - * For testing only, in development mode we use the router.isServer value - */ - server?: boolean -} - -type InterPolatePathResult = { - interpolatedPath: string - usedParams: Record - isMissingParams: boolean // true if any params were not available when being looked up in the params object -} - function encodeParam( key: string, value: unknown, - decoder: InterpolatePathOptions['decoder'], + decoder: ((encoded: string) => string) | undefined, ): string { if (typeof value !== 'string') { return '' + (value ?? undefined) @@ -256,47 +235,23 @@ function encodeParam( } /** - * Interpolate params and wildcards into a route path template. + * Interpolate params and wildcards into a route pathname. * * - Encodes params safely (configurable allowed characters) * - Supports `{-$optional}` segments, `{prefix{$id}suffix}` and `{$}` wildcards + * - Collects optional metadata in the same pass without allocating it for callers */ export function interpolatePath( - options: InterpolatePathOptions, -): InterPolatePathResult { - const { path, params, decoder, server } = options - const result: InterPolatePathResult = { - interpolatedPath: path || '/', - usedParams: Object.create(null), - isMissingParams: false, - } - result.interpolatedPath = interpolatePathname( - result.interpolatedPath, - params, - decoder, - result.usedParams, - undefined, - server, - result, - ) - return result -} - -/** - * @internal - * Optional metadata is collected in the same pass as the pathname. - */ -export function interpolatePathname( - path: string, + path: string | undefined, params: Record, - decoder: InterpolatePathOptions['decoder'], + decoder?: (encoded: string) => string, usedParams?: Record, keys?: Array, server?: boolean, metadata?: { isMissingParams: boolean }, ): string { - if (!path.includes('$')) { - return path + if (!path?.includes('$')) { + return path || '/' } if (isServer ?? server) { @@ -431,10 +386,7 @@ export function interpolatePathname( return joined || '/' } -function encodePathParam( - value: string, - decoder?: InterpolatePathOptions['decoder'], -) { +function encodePathParam(value: string, decoder?: (encoded: string) => string) { const encoded = encodeURIComponent(value) return decoder?.(encoded) ?? encoded } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 7e3a10c5d31..7600c2bc018 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -25,7 +25,7 @@ import { } from './new-process-route-tree' import { compileDecodeCharMap, - interpolatePathname, + interpolatePath, resolvePath, trimPath, trimPathRight, @@ -1649,7 +1649,7 @@ export class RouterCore< const usedParams: Record = createNull() const interpolatedPath = isServer === undefined - ? interpolatePathname( + ? interpolatePath( route.fullPath, rawParams, this.pathParamsDecoder, @@ -1657,7 +1657,7 @@ export class RouterCore< undefined, this.isServer, ) - : interpolatePathname( + : interpolatePath( route.fullPath, rawParams, this.pathParamsDecoder, @@ -1884,7 +1884,7 @@ export class RouterCore< const keys: Array = [] interpolated = isServer === undefined - ? interpolatePathname( + ? interpolatePath( path, params, decoder, @@ -1892,7 +1892,7 @@ export class RouterCore< keys, this.isServer, ) - : interpolatePathname(path, params, decoder, undefined, keys) + : interpolatePath(path, params, decoder, undefined, keys) plan = [keys, createSieveCache(128), decoder] this.pathCache.set(path, plan) } @@ -1904,7 +1904,7 @@ export class RouterCore< return ( interpolated || (isServer === undefined - ? interpolatePathname( + ? interpolatePath( path, params, decoder, @@ -1912,7 +1912,7 @@ export class RouterCore< undefined, this.isServer, ) - : interpolatePathname(path, params, decoder)) + : interpolatePath(path, params, decoder)) ) } key = keys.length === 1 ? value : key + value.length + ':' + value @@ -1925,7 +1925,7 @@ export class RouterCore< key, (interpolated ||= isServer === undefined - ? interpolatePathname( + ? interpolatePath( path, params, decoder, @@ -1933,7 +1933,7 @@ export class RouterCore< undefined, this.isServer, ) - : interpolatePathname(path, params, decoder)), + : interpolatePath(path, params, decoder)), ) return interpolated } diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 81d073efb94..d0125127c1d 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -41,7 +41,7 @@ test.each([false, true])( history: createMemoryHistory({ initialEntries: ['/items/one'] }), isServer, }) - const interpolate = vi.spyOn(pathUtils, 'interpolatePathname') + const interpolate = vi.spyOn(pathUtils, 'interpolatePath') try { const matches = router.matchRoutes('/items/one', {}) expect(matches.at(-1)?.pathname).toBe('/items/one') diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index e3a09c2cf25..2569cb8f387 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -87,53 +87,44 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('interpolatePath', () => { it('should interpolate optional dynamic params when present', () => { - const result = interpolatePath({ - path: '/posts/{-$category}', - params: { category: 'tech' }, + const result = interpolatePath('/posts/{-$category}', { + category: 'tech', }) - expect(result.interpolatedPath).toBe('/posts/tech') + expect(result).toBe('/posts/tech') }) it('should omit optional dynamic params when missing', () => { - const result = interpolatePath({ - path: '/posts/{-$category}', - params: {}, - }) - expect(result.interpolatedPath).toBe('/posts') + const result = interpolatePath('/posts/{-$category}', {}) + expect(result).toBe('/posts') }) it('should handle multiple optional dynamic params', () => { - const result1 = interpolatePath({ - path: '/posts/{-$category}/{-$slug}', - params: { category: 'tech', slug: 'hello' }, + const result1 = interpolatePath('/posts/{-$category}/{-$slug}', { + category: 'tech', + slug: 'hello', }) - expect(result1.interpolatedPath).toBe('/posts/tech/hello') + expect(result1).toBe('/posts/tech/hello') - const result2 = interpolatePath({ - path: '/posts/{-$category}/{-$slug}', - params: { category: 'tech' }, + const result2 = interpolatePath('/posts/{-$category}/{-$slug}', { + category: 'tech', }) - expect(result2.interpolatedPath).toBe('/posts/tech') + expect(result2).toBe('/posts/tech') - const result3 = interpolatePath({ - path: '/posts/{-$category}/{-$slug}', - params: {}, - }) - expect(result3.interpolatedPath).toBe('/posts') + const result3 = interpolatePath('/posts/{-$category}/{-$slug}', {}) + expect(result3).toBe('/posts') }) it('should handle mixed required and optional dynamic params', () => { - const result = interpolatePath({ - path: '/posts/{-$category}/user/$id', - params: { category: 'tech', id: '123' }, + const result = interpolatePath('/posts/{-$category}/user/$id', { + category: 'tech', + id: '123', }) - expect(result.interpolatedPath).toBe('/posts/tech/user/123') + expect(result).toBe('/posts/tech/user/123') - const result2 = interpolatePath({ - path: '/posts/{-$category}/user/$id', - params: { id: '123' }, + const result2 = interpolatePath('/posts/{-$category}/user/$id', { + id: '123', }) - expect(result2.interpolatedPath).toBe('/posts/user/123') + expect(result2).toBe('/posts/user/123') }) }) @@ -206,44 +197,41 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('Edge Cases', () => { it('should handle optional params with wildcards', () => { - const result = interpolatePath({ - path: '/docs/{-$version}/$', - params: { version: 'v1', _splat: 'guide/intro' }, + const result = interpolatePath('/docs/{-$version}/$', { + version: 'v1', + _splat: 'guide/intro', }) - expect(result.interpolatedPath).toBe('/docs/v1/guide/intro') + expect(result).toBe('/docs/v1/guide/intro') - const result2 = interpolatePath({ - path: '/docs/{-$version}/$', - params: { _splat: 'guide/intro' }, + const result2 = interpolatePath('/docs/{-$version}/$', { + _splat: 'guide/intro', }) - expect(result2.interpolatedPath).toBe('/docs/guide/intro') + expect(result2).toBe('/docs/guide/intro') }) it('should work with complex patterns', () => { const pattern = '/app/{-$env}/api/{-$version}/users/$id/{-$tab}' // All params provided - const result1 = interpolatePath({ - path: pattern, - params: { env: 'prod', version: 'v2', id: '123', tab: 'settings' }, + const result1 = interpolatePath(pattern, { + env: 'prod', + version: 'v2', + id: '123', + tab: 'settings', }) - expect(result1.interpolatedPath).toBe( - '/app/prod/api/v2/users/123/settings', - ) + expect(result1).toBe('/app/prod/api/v2/users/123/settings') // Only required param - const result2 = interpolatePath({ - path: pattern, - params: { id: '123' }, - }) - expect(result2.interpolatedPath).toBe('/app/api/users/123') + const result2 = interpolatePath(pattern, { id: '123' }) + expect(result2).toBe('/app/api/users/123') // Mix of optional and required - const result3 = interpolatePath({ - path: pattern, - params: { env: 'dev', id: '456', tab: 'profile' }, + const result3 = interpolatePath(pattern, { + env: 'dev', + id: '456', + tab: 'profile', }) - expect(result3.interpolatedPath).toBe('/app/dev/api/users/456/profile') + expect(result3).toBe('/app/dev/api/users/456/profile') }) }) }) diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index ec1c6bff929..b8584c9dc27 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -369,7 +369,7 @@ describe('Optional Path Parameters', () => { result: '/posts/42', }, ])('$name', ({ path, params, result }) => { - expect(interpolatePath({ path, params }).interpolatedPath).toBe(result) + expect(interpolatePath(path, params)).toBe(result) }) }) @@ -499,9 +499,7 @@ describe('Optional Path Parameters', () => { // This test will be expanded when we implement params.parse for optional params const path = '/posts/{-$category}' const params = { category: 'tech' } - expect(interpolatePath({ path, params }).interpolatedPath).toBe( - '/posts/tech', - ) + expect(interpolatePath(path, params)).toBe('/posts/tech') }) it('should handle multiple consecutive optional parameters correctly', () => { diff --git a/packages/router-core/tests/path-interpolation.bench.ts b/packages/router-core/tests/path-interpolation.bench.ts index 6b23200b8b0..f97a2c69463 100644 --- a/packages/router-core/tests/path-interpolation.bench.ts +++ b/packages/router-core/tests/path-interpolation.bench.ts @@ -1,16 +1,14 @@ import { bench, describe, expect } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute } from '../src' -import { - compileDecodeCharMap, - interpolatePath, - interpolatePathname, -} from '../src/path' +import { compileDecodeCharMap, interpolatePath } from '../src/path' import { createTestRouter } from './routerTestUtils' +import type { PathInterpolationTestOptions } from './routerTestUtils' -type Options = Parameters[0] - -const scenarios: Array<{ name: string; inputs: Array }> = [ +const scenarios: Array<{ + name: string + inputs: Array +}> = [ { name: 'single-param shared hits', inputs: Array.from({ length: 200 }, (_, index) => ({ @@ -118,25 +116,44 @@ describe.each(scenarios)('$name', ({ inputs }) => { }) router.pathParamsDecoder = decoder router.history.destroy() - const interpolate = router['interpolatePath'] const calls = inputs .filter((input) => input.path?.includes('$')) .map((input) => ({ - args: - interpolate.length === 1 ? [input] : [input.path || '/', input.params], - expected: interpolatePath(input).interpolatedPath, + path: input.path || '/', + params: input.params, + expected: interpolatePath( + input.path, + input.params, + input.decoder, + undefined, + undefined, + input.server, + ), })) let checksum = 0 const expected = inputs.reduce( - (sum, input) => sum + interpolatePath(input).interpolatedPath.length, + (sum, input) => + sum + + interpolatePath( + input.path, + input.params, + input.decoder, + undefined, + undefined, + input.server, + ).length, 0, ) const cachedExpected = calls.reduce((sum, call) => { - expect(Reflect.apply(interpolate, router, call.args)).toBe(call.expected) + expect(router['interpolatePath'](call.path, call.params)).toBe( + call.expected, + ) return sum + call.expected.length }, 0) for (const call of calls) { - expect(Reflect.apply(interpolate, router, call.args)).toBe(call.expected) + expect(router['interpolatePath'](call.path, call.params)).toBe( + call.expected, + ) } bench( @@ -144,7 +161,7 @@ describe.each(scenarios)('$name', ({ inputs }) => { () => { let length = 0 for (const call of calls) { - length += Reflect.apply(interpolate, router, call.args).length + length += router['interpolatePath'](call.path, call.params).length } checksum = length }, @@ -163,8 +180,8 @@ describe.each(scenarios)('$name', ({ inputs }) => { () => { let length = 0 for (const input of inputs) { - length += interpolatePathname( - input.path || '/', + length += interpolatePath( + input.path, input.params, input.decoder, undefined, @@ -185,11 +202,19 @@ describe.each(scenarios)('$name', ({ inputs }) => { ) bench( - 'uncached interpolation batch', + 'metadata-enabled interpolation batch', () => { let length = 0 for (const input of inputs) { - length += interpolatePath(input).interpolatedPath.length + length += interpolatePath( + input.path, + input.params, + input.decoder, + Object.create(null), + undefined, + input.server, + { isMissingParams: false }, + ).length } checksum = length }, diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 8ee69bb4d74..d1326acd0f8 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -3,7 +3,6 @@ import { compileDecodeCharMap, exactPathTest, interpolatePath, - interpolatePathname, removeTrailingSlash, resolvePath, trimPathLeft, @@ -24,34 +23,84 @@ describe.each([false, true])( 'shared pathname interpolation (server: %s)', (server) => { it.each([ - { path: '/', params: {} }, - { path: '/users/', params: {} }, - { path: '/users/$id', params: { id: '123' } }, - { path: '/users/$id', params: { id: 0 } }, - { path: '/users/$id', params: {} }, - { path: '/users/$id', params: { id: 'a/b?#@+' } }, - { path: '/users/$id', params: { id: 'cafe\u0301' } }, - { path: '/users/{$id}.json', params: { id: '123' } }, - { path: '/posts/{-$category}', params: {} }, - { path: '/posts/{-$category}', params: { category: undefined } }, - { path: '/posts/{-$category}', params: { category: '' } }, - { path: '/posts/{-$category}', params: { category: 'news' } }, + { path: '/', params: {}, expected: '/' }, + { path: '/users/', params: {}, expected: '/users/' }, + { path: '/users/$id', params: { id: '123' }, expected: '/users/123' }, + { path: '/users/$id', params: { id: 0 }, expected: '/users/0' }, + { path: '/users/$id', params: {}, expected: '/users/undefined' }, + { + path: '/users/$id', + params: { id: 'a/b?#@+' }, + expected: '/users/a%2Fb%3F%23%40%2B', + }, + { + path: '/users/$id', + params: { id: 'cafe\u0301' }, + expected: '/users/cafe%CC%81', + }, + { + path: '/users/{$id}.json', + params: { id: '123' }, + expected: '/users/123.json', + }, + { path: '/posts/{-$category}', params: {}, expected: '/posts' }, + { + path: '/posts/{-$category}', + params: { category: undefined }, + expected: '/posts', + }, + { + path: '/posts/{-$category}', + params: { category: '' }, + expected: '/posts/', + }, + { + path: '/posts/{-$category}', + params: { category: 'news' }, + expected: '/posts/news', + }, { path: '/posts/prefix{-$category}suffix', params: { category: 'news' }, + expected: '/posts/prefixnewssuffix', }, - { path: '/files/$', params: { _splat: 'a b/c+d' } }, - { path: '/files/prefix{$}suffix', params: { _splat: 'a/b' } }, - { path: '/files/$', params: { _splat: '' } }, - { path: '/$id/$id/', params: { id: '123' } }, - ])('matches interpolation for $path with $params', ({ path, params }) => { - const interpolate = createPathInterpolator() - const options = { path, params, server } - const expected = interpolatePath(options).interpolatedPath - - expect(interpolate(options)).toBe(expected) - expect(interpolate({ ...options, params: { ...params } })).toBe(expected) - }) + { + path: '/files/$', + params: { _splat: 'a b/c+d' }, + expected: '/files/a%20b/c%2Bd', + }, + { + path: '/files/prefix{$}suffix', + params: { _splat: 'a/b' }, + expected: '/files/prefixa/bsuffix', + }, + { path: '/files/$', params: { _splat: '' }, expected: '/files' }, + { + path: '/$id/$id/', + params: { id: '123' }, + expected: '/123/123/', + }, + ])( + 'interpolates and caches $path with $params', + ({ path, params, expected }) => { + const interpolate = createPathInterpolator() + const options = { path, params, server } + expect( + interpolatePath( + path, + params, + undefined, + undefined, + undefined, + server, + ), + ).toBe(expected) + expect(interpolate(options)).toBe(expected) + expect(interpolate({ ...options, params: { ...params } })).toBe( + expected, + ) + }, + ) it('shares results across equivalent params without retaining unrelated params', () => { const interpolate = createPathInterpolator() @@ -98,12 +147,16 @@ describe.each([false, true])( const interpolate = createPathInterpolator() const allowAt = compileDecodeCharMap(['@']) const allowPlus = compileDecodeCharMap(['+']) - for (const decoder of [allowAt, allowPlus, undefined, allowAt]) { - for (const path of ['/users/$id', '/teams/$id']) { + for (const [decoder, encoded] of [ + [allowAt, '@%2B'], + [allowPlus, '%40+'], + [undefined, '%40%2B'], + [allowAt, '@%2B'], + ] as const) { + for (const prefix of ['/users/', '/teams/']) { + const path = prefix + '$id' const options = { path, params: { id: '@+' }, decoder, server } - expect(interpolate(options)).toBe( - interpolatePath(options).interpolatedPath, - ) + expect(interpolate(options)).toBe(prefix + encoded) } } }) @@ -138,18 +191,25 @@ describe.each([false, true])( const interpolate = createPathInterpolator() const path = '/posts/{-$category}/$id' const inputs = [ - { id: 'one' }, - { id: 'one', category: 'news' }, - { id: 'one', category: undefined }, - { id: 'one', category: '' }, - { id: 'one', category: 'updates' }, + { params: { id: 'one' }, expected: '/posts/one' }, + { + params: { id: 'one', category: 'news' }, + expected: '/posts/news/one', + }, + { + params: { id: 'one', category: undefined }, + expected: '/posts/one', + }, + { params: { id: 'one', category: '' }, expected: '/posts//one' }, + { + params: { id: 'one', category: 'updates' }, + expected: '/posts/updates/one', + }, ] - for (const params of [...inputs, ...inputs]) { + for (const { params, expected } of [...inputs, ...inputs]) { const options = { path, params, server } - expect(interpolate(options)).toBe( - interpolatePath(options).interpolatedPath, - ) + expect(interpolate(options)).toBe(expected) } }) @@ -174,18 +234,30 @@ describe.each([false, true])( expect(decoder).not.toHaveBeenCalled() }) - it('keeps public usedParams unchanged for missing optionals and splats', () => { - expect( - interpolatePath({ path: '/posts/{-$category}', params: {}, server }) - .usedParams, - ).toEqual({}) - expect( - interpolatePath({ - path: '/files/$', - params: { _splat: 'docs/guide' }, - server, - }).usedParams, - ).toEqual({ _splat: 'docs/guide', '*': 'docs/guide' }) + it('collects used params for splats but not missing optionals', () => { + const usedParams: Record = Object.create(null) + interpolatePath( + '/posts/{-$category}', + {}, + undefined, + usedParams, + undefined, + server, + ) + expect(usedParams).toEqual({}) + interpolatePath( + '/files/$', + { _splat: 'docs/guide' }, + undefined, + usedParams, + undefined, + server, + ) + expect(usedParams).toEqual({ + _splat: 'docs/guide', + '*': 'docs/guide', + }) + expect(Object.getPrototypeOf(usedParams)).toBeNull() }) it.each([ @@ -219,7 +291,7 @@ describe.each([false, true])( const keys: Array = [] const metadata = { isMissingParams: false } expect( - interpolatePathname( + interpolatePath( template, params, undefined, @@ -232,11 +304,16 @@ describe.each([false, true])( expect(keys).toEqual(['id', 'language', '_splat']) expect(usedParams).toEqual(used) expect(metadata.isMissingParams).toBe(missing) - expect(interpolatePath({ path: template, params, server })).toEqual({ - interpolatedPath: path, - usedParams: used, - isMissingParams: missing, - }) + expect( + interpolatePath( + template, + params, + undefined, + undefined, + undefined, + server, + ), + ).toBe(path) }, ) @@ -279,11 +356,21 @@ describe.each([false, true])( ])( 'preserves missing-param metadata for $path with $params', ({ path, params, pathname, usedParams, missing }) => { - expect(interpolatePath({ path, params, server })).toEqual({ - interpolatedPath: pathname, - usedParams, - isMissingParams: missing, - }) + const collectedParams: Record = Object.create(null) + const metadata = { isMissingParams: false } + expect( + interpolatePath( + path, + params, + undefined, + collectedParams, + undefined, + server, + metadata, + ), + ).toBe(pathname) + expect(collectedParams).toEqual(usedParams) + expect(metadata.isMissingParams).toBe(missing) }, ) @@ -295,9 +382,7 @@ describe.each([false, true])( params: { _splat }, server, } - expect(interpolate(options)).toBe( - interpolatePath(options).interpolatedPath, - ) + expect(interpolate(options)).toBe(`/files/prefix${_splat}suffix`) } }) @@ -598,6 +683,77 @@ describe('resolvePath', () => { describe.each([{ server: true }, { server: false }])( 'interpolatePath (server: $server)', ({ server }) => { + it.each([ + { path: undefined, expected: '/' }, + { path: '', expected: '/' }, + { path: '/', expected: '/' }, + { path: '/about/', expected: '/about/' }, + ])('preserves static and empty paths: $path', ({ path, expected }) => { + const params = { + get unused() { + throw new Error('Static paths must not read params') + }, + } + const decoder = vi.fn() + const usedParams: Record = Object.create(null) + const keys: Array = [] + const metadata = { isMissingParams: false } + expect( + interpolatePath( + path, + params, + decoder, + usedParams, + keys, + server, + metadata, + ), + ).toBe(expected) + expect(usedParams).toEqual({}) + expect(keys).toEqual([]) + expect(metadata.isMissingParams).toBe(false) + expect(decoder).not.toHaveBeenCalled() + }) + + it.each([ + { + path: '/users/$id', + params: {}, + expected: '/users/undefined', + missing: true, + }, + { + path: '/users/$id', + params: { id: 'one' }, + expected: '/users/one', + missing: false, + }, + { + path: '/posts/{-$category}', + params: {}, + expected: '/posts', + missing: false, + }, + { path: '/files/$', params: {}, expected: '/files', missing: true }, + ])( + 'collects only missing status for $path', + ({ path, params, expected, missing }) => { + const metadata = { isMissingParams: false } + expect( + interpolatePath( + path, + params, + undefined, + undefined, + undefined, + server, + metadata, + ), + ).toBe(expected) + expect(metadata.isMissingParams).toBe(missing) + }, + ) + describe('regular usage', () => { it.each([ { @@ -704,12 +860,7 @@ describe.each([{ server: true }, { server: false }])( }, ])('$name', ({ path, params, decoder, result }) => { expect( - interpolatePath({ - path, - params, - decoder, - server, - }).interpolatedPath, + interpolatePath(path, params, decoder, undefined, undefined, server), ).toBe(result) }) }) @@ -740,11 +891,14 @@ describe.each([{ server: true }, { server: false }])( 'should preserve trailing slash for $path', ({ path, params, result }) => { expect( - interpolatePath({ + interpolatePath( path, params, + undefined, + undefined, + undefined, server, - }).interpolatedPath, + ), ).toBe(result) }, ) @@ -784,11 +938,7 @@ describe.each([{ server: true }, { server: false }])( }, ])('$name', ({ to, params, result }) => { expect( - interpolatePath({ - path: to, - params, - server, - }).interpolatedPath, + interpolatePath(to, params, undefined, undefined, undefined, server), ).toBe(result) }) }) @@ -851,11 +1001,14 @@ describe.each([{ server: true }, { server: false }])( }, ])('$name', ({ path, params, result }) => { expect( - interpolatePath({ + interpolatePath( path, params, + undefined, + undefined, + undefined, server, - }).interpolatedPath, + ), ).toBe(result) }) }) @@ -900,11 +1053,7 @@ describe.each([{ server: true }, { server: false }])( }, ])('$name', ({ to, params, result }) => { expect( - interpolatePath({ - path: to, - params, - server, - }).interpolatedPath, + interpolatePath(to, params, undefined, undefined, undefined, server), ).toBe(result) }) }) @@ -952,13 +1101,18 @@ describe.each([{ server: true }, { server: false }])( expectedResult: '/hello', }, ])('$name', ({ path, params, expectedResult }) => { - const result = interpolatePath({ + const metadata = { isMissingParams: false } + const result = interpolatePath( path, params, + undefined, + undefined, + undefined, server, - }) - expect(result.interpolatedPath).toBe(expectedResult) - expect(result.isMissingParams).toBe(true) + metadata, + ) + expect(result).toBe(expectedResult) + expect(metadata.isMissingParams).toBe(true) }) }) @@ -979,11 +1133,14 @@ describe.each([{ server: true }, { server: false }])( trailingSlash, }) const nextParams = { _splat: '' } - const interpolatedNextTo = interpolatePath({ - path: nextTo, - params: nextParams, + const interpolatedNextTo = interpolatePath( + nextTo, + nextParams, + undefined, + undefined, + undefined, server, - }).interpolatedPath + ) expect(interpolatedNextTo).toBe(`/splat${tail}`) }, ) diff --git a/packages/router-core/tests/routerTestUtils.ts b/packages/router-core/tests/routerTestUtils.ts index a52f82eb6ad..8e1b6ee533a 100644 --- a/packages/router-core/tests/routerTestUtils.ts +++ b/packages/router-core/tests/routerTestUtils.ts @@ -52,6 +52,13 @@ export function createTestRouter< return new RouterCore(options, getStoreConfig) } +export type PathInterpolationTestOptions = { + path?: string + params: Record + decoder?: Parameters[2] + server?: boolean +} + export function createTestPathInterpolator() { const router = createTestRouter({ routeTree: new BaseRootRoute({}), @@ -59,21 +66,10 @@ export function createTestPathInterpolator() { scrollRestoration: false, }) router.history.destroy() - const interpolate = router['interpolatePath'] - - return (options: Parameters[0]): string => { + return (options: PathInterpolationTestOptions): string => { router.isServer = options.server ?? false router.pathParamsDecoder = options.decoder - // Support both sides of the factory-to-method benchmark comparison. - const args = - interpolate.length === 1 - ? [options] - : [options.path || '/', options.params] - const result = Reflect.apply(interpolate, router, args) - if (typeof result !== 'string') { - throw new Error('Expected an interpolated pathname') - } - return result + return router['interpolatePath'](options.path || '/', options.params) } } diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 966eb32bc52..17f5776330d 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -179,19 +179,18 @@ function RouteComp({ // flatten all params in the router state, into a single object const allParams = Object.assign({}, ...matches().map((m) => m.params)) - // interpolatePath is used by router-core to generate the `to` - // path for the navigate function in the router - const interpolated = interpolatePath({ - path: route.fullPath, - params: allParams, - decoder: router().pathParamsDecoder, - }) + const metadata = { isMissingParams: false } + const pathname = interpolatePath( + route.fullPath, + allParams, + router().pathParamsDecoder, + undefined, + undefined, + undefined, + metadata, + ) - // only if `interpolated` is not missing params, return the path since this - // means that all the params are present for a successful navigation - return !interpolated.isMissingParams - ? interpolated.interpolatedPath - : undefined + return metadata.isMissingParams ? undefined : pathname }) return ( diff --git a/packages/router-devtools-core/tests/interpolation-navigation.test.ts b/packages/router-devtools-core/tests/interpolation-navigation.test.ts new file mode 100644 index 00000000000..8b3f91738df --- /dev/null +++ b/packages/router-devtools-core/tests/interpolation-navigation.test.ts @@ -0,0 +1,90 @@ +import { beforeAll, expect, test, vi } from 'vitest' +import { + BaseRootRoute, + BaseRoute, + RouterCore, + createNonReactiveMutableStore, + createNonReactiveReadonlyStore, +} from '@tanstack/router-core' +import { TanStackRouterDevtoolsPanelCore } from '../src/TanStackRouterDevtoolsPanelCore' + +beforeAll(async () => { + await import('../src/BaseTanStackRouterDevtoolsPanel') +}, 30_000) + +test('offers navigation only when required and splat params are available', async () => { + const root = new BaseRootRoute({}) + const routeTree = root.addChildren( + ['/users/$id', '/posts/{-$category}', '/files/$'].map( + (path) => new BaseRoute({ getParentRoute: () => root, path }), + ), + ) + const router = new RouterCore({ routeTree, isServer: false }, () => ({ + createMutableStore: createNonReactiveMutableStore, + createReadonlyStore: createNonReactiveReadonlyStore, + batch: (fn) => fn(), + })) + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue() + const initialState = router.state + const panel = new TanStackRouterDevtoolsPanelCore({ + router, + routerState: initialState, + }) + const container = document.createElement('div') + document.body.append(container) + panel.mount(container) + + try { + await vi.waitFor(() => { + expect( + container.querySelector('[title="Navigate to /posts"]'), + ).not.toBeNull() + }) + expect(container.querySelector('[title^="Navigate to /users/"]')).toBeNull() + expect(container.querySelector('[title^="Navigate to /files"]')).toBeNull() + + panel.setRouterState({ + ...initialState, + matches: router.matchRoutes('/users/item%20one', {}), + }) + await vi.waitFor(() => { + expect( + container.querySelector('[title="Navigate to /users/item%20one"]'), + ).not.toBeNull() + }) + container + .querySelector( + '[title="Navigate to /users/item%20one"]', + )! + .click() + expect(navigate).toHaveBeenCalledWith({ + to: '/users/item%20one', + params: undefined, + search: undefined, + }) + + panel.setRouterState({ + ...initialState, + matches: router.matchRoutes('/files/docs/guide', {}), + }) + await vi.waitFor(() => { + expect( + container.querySelector('[title="Navigate to /files/docs/guide"]'), + ).not.toBeNull() + }) + expect(container.querySelector('[title^="Navigate to /users/"]')).toBeNull() + + panel.setRouterState(initialState) + await vi.waitFor(() => { + expect( + container.querySelector('[title^="Navigate to /files"]'), + ).toBeNull() + }) + } finally { + panel.unmount() + router.history.destroy() + navigate.mockRestore() + container.remove() + window.localStorage.clear() + } +}) diff --git a/packages/solid-router/tests/link.bench.tsx b/packages/solid-router/tests/link.bench.tsx index 7d60d9ebd56..6c83fa0a91a 100644 --- a/packages/solid-router/tests/link.bench.tsx +++ b/packages/solid-router/tests/link.bench.tsx @@ -38,8 +38,11 @@ const InterpolatePathLink = ({ to, params, children, -}: Solid.PropsWithChildren) => { - const href = interpolatePath({ path: to, params }).interpolatedPath +}: Solid.PropsWithChildren<{ + to: string + params: Record +}>) => { + const href = interpolatePath(to, params) return {children} } diff --git a/packages/vue-router/tests/link.bench.tsx b/packages/vue-router/tests/link.bench.tsx index 8a30d80a2d3..6448c893689 100644 --- a/packages/vue-router/tests/link.bench.tsx +++ b/packages/vue-router/tests/link.bench.tsx @@ -37,10 +37,7 @@ const createRouterRenderer = (routesCount: number) => (children: Vue.VNode) => { const InterpolatePathLink = Vue.defineComponent({ props: ['to', 'params'], setup(props, { slots }) { - const href = interpolatePath({ - path: props.to, - params: props.params, - }).interpolatedPath + const href = interpolatePath(props.to, props.params) return () => Vue.h('a', { href }, slots.default?.()) }, From ec05cc6bec149d285b9b82f3d821640aec957837 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:45:50 +0200 Subject: [PATCH 17/19] perf(router-core): keep derived caches on routes Store branches and interpolation plans directly on route objects. Use the already-resolved destination route, remove the fixed outer limit for registered templates, and retain128 results per route. Keep a bounded32-template fallback for arbitrary templates and masks. Build/install route-tree indexes and string caches as one bundle for existing SSR reuse. Reinitialization clears ancestor branches; interpolation plans validate their exact template and decoder without unconditional resets. Loaded match data remains request-local. Against29509b9b98, paired core buildLocation batches over64/256 templates use40-43% less CPU; four fresh server routers generating200 hrefs each use8-9% less. Reversed import order corroborates these scoped results. Four real client/SSR Link cases remain statistically inconclusive. All18 bundle fixtures shrink19-37 gzip bytes versus the consolidation baseline. React Router minimal/full are85797/89379 bytes,46/38 below measured main. Tests cover SSR request cleanup, route reuse, decoder/trailing variants, reparenting, result bounds and fallback capacity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/bright-dryers-vanish.md | 5 + packages/router-core/src/path.ts | 7 + packages/router-core/src/route.ts | 11 + packages/router-core/src/router.ts | 68 ++--- .../router-core/tests/build-location.test.ts | 4 +- .../tests/path-interpolation.bench.ts | 45 ++- .../tests/route-tree-caches.test.ts | 288 ++++++++++++++++++ 7 files changed, 380 insertions(+), 48 deletions(-) create mode 100644 .changeset/bright-dryers-vanish.md create mode 100644 packages/router-core/tests/route-tree-caches.test.ts diff --git a/.changeset/bright-dryers-vanish.md b/.changeset/bright-dryers-vanish.md new file mode 100644 index 00000000000..6216bd3e831 --- /dev/null +++ b/.changeset/bright-dryers-vanish.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Cache route branches and interpolated paths on their route objects, removing the fixed template limit for registered routes and reusing cached paths across server requests. Rebuild tree-dependent caches together, preserve decoder and trailing-slash isolation, and keep unregistered templates bounded. diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index f81f24aae59..1b564382ec8 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -208,6 +208,13 @@ export function compileDecodeCharMap( encoded.replace(regex, (match) => charMap.get(match) ?? match) } +export type InterpolationPlan = [ + keys: Array, + paths: SieveCache, + decoder: ((encoded: string) => string) | undefined, + path: string, +] + function encodeParam( key: string, value: unknown, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 70bfdc46cdc..4451242456e 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -4,6 +4,7 @@ import { notFound } from './not-found' import { redirect } from './redirect' import { rootRouteId } from './root' import type { LazyRoute } from './fileRoute' +import type { InterpolationPlan } from './path' import type { NotFoundError } from './not-found' import type { RedirectFnRoute } from './redirect' import type { NavigateOptions, ParsePathParams } from './link' @@ -726,6 +727,10 @@ export interface Route< > /** @internal */ _lazy?: Promise | true + /** @internal */ + _branch?: ReadonlyArray + /** @internal */ + _pathCache?: InterpolationPlan rank: number to: TrimPathRight init: (opts: { originalIndex: number }) => void @@ -1713,6 +1718,10 @@ export class BaseRoute< > /** @internal */ _lazy?: Promise | true + /** @internal */ + _branch?: ReadonlyArray + /** @internal */ + _pathCache?: InterpolationPlan constructor( options?: RouteOptions< TRegister, @@ -1764,6 +1773,8 @@ export class BaseRoute< init = (opts: { originalIndex: number }): void => { this.originalIndex = opts.originalIndex + // Rebuilding a tree can change the ancestors of an existing route. + this._branch = undefined const options = this.options as | (RouteOptions< diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 7600c2bc018..76a706b7d2c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -51,6 +51,7 @@ import { } from './rewrite' import { createRouterStores } from './stores' import type { SieveCache } from './sieve-cache' +import type { InterpolationPlan } from './path' import type { ProcessRouteTreeResult, ProcessedTree, @@ -993,11 +994,12 @@ type LightweightRouteMatchCacheEntry = [ result: LightweightRouteMatchResult, ] -type InterpolationPlan = [ - keys: Array, - paths: SieveCache, - decoder: ((encoded: string) => string) | undefined, -] +/** Indexes and caches rebuilt together when the route tree changes. */ +type RouteTreeCaches = + ProcessRouteTreeResult & { + resolvePathCache: SieveCache + unmatchedPathCache: SieveCache + } export type CreateRouterFn = < TRouteTree extends AnyRoute, @@ -1028,8 +1030,7 @@ declare global { var __TSR_CACHE__: | { routeTree: AnyRoute - processRouteTreeResult: ProcessRouteTreeResult - resolvePathCache: SieveCache + processRouteTreeResult: RouteTreeCaches } | undefined } @@ -1139,8 +1140,7 @@ export class RouterCore< routesByPath!: RoutesByPath processedTree!: ProcessedTree resolvePathCache!: SieveCache - private pathCache = createSieveCache(32) - private routeBranchCache = new WeakMap>() + private unmatchedPathCache!: SieveCache private lightweightCache = new WeakMap< ParsedLocation, LightweightRouteMatchCacheEntry @@ -1259,7 +1259,7 @@ export class RouterCore< if (this.options.routeTree !== this.routeTree) { this.routeTree = this.options.routeTree as TRouteTree - let processRouteTreeResult: ProcessRouteTreeResult + let processRouteTreeResult: RouteTreeCaches if ( process.env.NODE_ENV !== 'development' && (isServer ?? this.isServer) && @@ -1267,10 +1267,8 @@ export class RouterCore< globalThis.__TSR_CACHE__.routeTree === this.routeTree ) { const cached = globalThis.__TSR_CACHE__ - this.resolvePathCache = cached.resolvePathCache processRouteTreeResult = cached.processRouteTreeResult as any } else { - this.resolvePathCache = createSieveCache(1000) processRouteTreeResult = this.buildRouteTree() // only cache if nothing else is cached yet if ( @@ -1281,7 +1279,6 @@ export class RouterCore< globalThis.__TSR_CACHE__ = { routeTree: this.routeTree, processRouteTreeResult: processRouteTreeResult as any, - resolvePathCache: this.resolvePathCache, } } } @@ -1347,7 +1344,7 @@ export class RouterCore< ) } - buildRouteTree = () => { + buildRouteTree = (): RouteTreeCaches => { const result = processRouteTree( this.routeTree, this.options.caseSensitive, @@ -1361,17 +1358,16 @@ export class RouterCore< processRouteMasks(this.options.routeMasks, result.processedTree) } - return result + return { + ...result, + resolvePathCache: createSieveCache(1000), + // Arbitrary templates (for example, masks) have no route object to key by. + unmatchedPathCache: createSieveCache(32), + } } - setRoutes({ - routesById, - routesByPath, - processedTree, - }: ProcessRouteTreeResult) { - this.routesById = routesById as RoutesById - this.routesByPath = routesByPath as RoutesByPath - this.processedTree = processedTree + setRoutes(caches: RouteTreeCaches) { + Object.assign(this, caches) const notFoundRoute = this.options.notFoundRoute @@ -1510,15 +1506,6 @@ export class RouterCore< }) } - private getRouteBranch(route: AnyRoute) { - let branch = this.routeBranchCache.get(route) - if (!branch) { - branch = buildRouteBranch(route) - this.routeBranchCache.set(route, branch) - } - return branch - } - matchRoutes: MatchRoutesFn = ( pathnameOrNext: string | ParsedLocation, locationSearchOrOpts?: AnySchema | MatchRoutesOpts, @@ -1876,11 +1863,12 @@ export class RouterCore< private interpolatePath( path: string, params: Record, + route?: AnyRoute, ): string { const decoder = this.pathParamsDecoder - let plan = this.pathCache.get(path) + let plan = route ? route._pathCache : this.unmatchedPathCache.get(path) let interpolated: string | undefined - if (!plan || plan[2] !== decoder) { + if (!plan || plan[2] !== decoder || plan[3] !== path) { const keys: Array = [] interpolated = isServer === undefined @@ -1893,8 +1881,12 @@ export class RouterCore< this.isServer, ) : interpolatePath(path, params, decoder, undefined, keys) - plan = [keys, createSieveCache(128), decoder] - this.pathCache.set(path, plan) + plan = [keys, createSieveCache(128), decoder, path] + if (route) { + route._pathCache = plan + } else { + this.unmatchedPathCache.set(path, plan) + } } const [keys, paths] = plan let key = '' @@ -2023,7 +2015,7 @@ export class RouterCore< let destRoutes: ReadonlyArray if (destRoute) { - destRoutes = this.getRouteBranch(destRoute) + destRoutes = destRoute._branch ??= buildRouteBranch(destRoute) } else if (nextTo.includes('$')) { // Route templates must match routesByPath exactly. A miss here is a // typed destination mismatch, not a concrete URL to route-match. @@ -2068,7 +2060,7 @@ export class RouterCore< nextTo : decodePath( nextTo.includes('$') - ? this.interpolatePath(nextTo, nextParams) + ? this.interpolatePath(nextTo, nextParams, destRoute) : nextTo, ).path diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index d0125127c1d..0beb779333d 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -56,7 +56,7 @@ test.each([false, true])( }, ) -test('keeps interpolation caches router-local and follows decoder changes', () => { +test('shares interpolation on reused routes and follows decoder changes', () => { const rootRoute = new BaseRootRoute({}) const route = new BaseRoute({ getParentRoute: () => rootRoute, @@ -86,7 +86,7 @@ test('keeps interpolation caches router-local and follows decoder changes', () = expect( other.buildLocation({ to: '/items/$id', params: { id: '@one' } }).href, ).toBe('/items/@one') - expect(decoder).toHaveBeenCalledOnce() + expect(decoder).not.toHaveBeenCalled() router.pathParamsDecoder = pathUtils.compileDecodeCharMap(['+']) expect( diff --git a/packages/router-core/tests/path-interpolation.bench.ts b/packages/router-core/tests/path-interpolation.bench.ts index f97a2c69463..8f4b6c1e5cb 100644 --- a/packages/router-core/tests/path-interpolation.bench.ts +++ b/packages/router-core/tests/path-interpolation.bench.ts @@ -1,6 +1,6 @@ import { bench, describe, expect } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute } from '../src' +import { BaseRootRoute, BaseRoute } from '../src' import { compileDecodeCharMap, interpolatePath } from '../src/path' import { createTestRouter } from './routerTestUtils' import type { PathInterpolationTestOptions } from './routerTestUtils' @@ -8,6 +8,7 @@ import type { PathInterpolationTestOptions } from './routerTestUtils' const scenarios: Array<{ name: string inputs: Array + register?: boolean }> = [ { name: 'single-param shared hits', @@ -34,7 +35,22 @@ const scenarios: Array<{ })), }, { - name: 'template eviction', + name: '64-template working set', + inputs: Array.from({ length: 64 }, (_, index) => ({ + path: `/section-${index}/$id`, + params: { id: 'item one' }, + })), + }, + { + name: '256-template working set', + inputs: Array.from({ length: 256 }, (_, index) => ({ + path: `/section-${index}/$id`, + params: { id: 'item one' }, + })), + }, + { + name: 'unregistered template eviction', + register: false, inputs: Array.from({ length: 64 }, (_, index) => ({ path: `/section-${index}/$id`, params: { id: 'item one' }, @@ -101,15 +117,23 @@ for (const { inputs } of scenarios) { } } scenarios.push( - ...scenarios.map(({ name, inputs }) => ({ + ...scenarios.map(({ name, inputs, register }) => ({ name: `server ${name}`, inputs: inputs.map((input) => ({ ...input, server: true })), + register, })), ) -describe.each(scenarios)('$name', ({ inputs }) => { +describe.each(scenarios)('$name', ({ inputs, register = true }) => { + const root = new BaseRootRoute({}) + const routes = new Map( + [...new Set(inputs.map((input) => input.path || '/'))].map((path) => [ + path, + new BaseRoute({ getParentRoute: () => root, path }), + ]), + ) const router = createTestRouter({ - routeTree: new BaseRootRoute({}), + routeTree: register ? root.addChildren([...routes.values()]) : root, history: createMemoryHistory({ initialEntries: ['/'] }), isServer: inputs[0]?.server, scrollRestoration: false, @@ -121,6 +145,7 @@ describe.each(scenarios)('$name', ({ inputs }) => { .map((input) => ({ path: input.path || '/', params: input.params, + route: register ? routes.get(input.path || '/') : undefined, expected: interpolatePath( input.path, input.params, @@ -145,13 +170,13 @@ describe.each(scenarios)('$name', ({ inputs }) => { 0, ) const cachedExpected = calls.reduce((sum, call) => { - expect(router['interpolatePath'](call.path, call.params)).toBe( + expect(router['interpolatePath'](call.path, call.params, call.route)).toBe( call.expected, ) return sum + call.expected.length }, 0) for (const call of calls) { - expect(router['interpolatePath'](call.path, call.params)).toBe( + expect(router['interpolatePath'](call.path, call.params, call.route)).toBe( call.expected, ) } @@ -161,7 +186,11 @@ describe.each(scenarios)('$name', ({ inputs }) => { () => { let length = 0 for (const call of calls) { - length += router['interpolatePath'](call.path, call.params).length + length += router['interpolatePath']( + call.path, + call.params, + call.route, + ).length } checksum = length }, diff --git a/packages/router-core/tests/route-tree-caches.test.ts b/packages/router-core/tests/route-tree-caches.test.ts new file mode 100644 index 00000000000..85ea2ec1afa --- /dev/null +++ b/packages/router-core/tests/route-tree-caches.test.ts @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import * as pathUtils from '../src/path' +import { createRequestHandler } from '../src/ssr/createRequestHandler' +import { createTestRouter } from './routerTestUtils' +import type { AnyRoute, AnyRouter } from '../src' + +const disposers: Array<() => void> = [] + +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubGlobal('__TSR_CACHE__', undefined) +}) + +afterEach(() => { + for (const dispose of disposers.splice(0)) { + dispose() + } + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +function createRoutes() { + const root = new BaseRootRoute({}) + const item = new BaseRoute({ + getParentRoute: () => root, + path: '/items/$id', + }) + return { root, item, routeTree: root.addChildren([item]) } +} + +function history() { + const result = createMemoryHistory({ initialEntries: ['/'] }) + disposers.push(result.destroy) + return result +} + +test('reuses route caches after server request cleanup without sharing match state', async () => { + const { routeTree, item, root } = createRoutes() + const routers: Array = [] + const request = () => + createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => { + const router = createTestRouter({ routeTree, isServer: true }) + routers.push(router) + return router + }, + })(({ router }) => { + disposers.push(router.history.destroy) + return new Response( + router.buildLocation({ + to: '/items/$id', + params: { id: 'one two' }, + }).href, + ) + }) + + expect(await (await request()).text()).toBe('/items/one%20two') + const branch = item._branch + const plan = item._pathCache + expect(branch).toEqual([root, item]) + expect(plan).toBeDefined() + const interpolate = vi.spyOn(pathUtils, 'interpolatePath') + + expect(await (await request()).text()).toBe('/items/one%20two') + expect(item._branch).toBe(branch) + expect(item._pathCache).toBe(plan) + expect( + interpolate.mock.calls.filter(([path]) => path === '/items/$id'), + ).toHaveLength(0) + expect(routers[0]).not.toBe(routers[1]) + expect(routers[0]!.resolvePathCache).toBe(routers[1]!.resolvePathCache) + expect(routers[0]!._cache).not.toBe(routers[1]!._cache) +}) + +test.each([ + { mode: 'production', isServer: false }, + { mode: 'development', isServer: true }, +])( + 'does not share router cache groups in $mode (server: $isServer)', + ({ mode, isServer }) => { + vi.stubEnv('NODE_ENV', mode) + const { routeTree } = createRoutes() + const first = createTestRouter({ routeTree, history: history(), isServer }) + const second = createTestRouter({ routeTree, history: history(), isServer }) + expect(first.resolvePathCache).not.toBe(second.resolvePathCache) + expect(first['unmatchedPathCache']).not.toBe(second['unmatchedPathCache']) + expect(globalThis.__TSR_CACHE__).toBeUndefined() + }, +) + +test('keeps different route objects independent and resets derived caches when rebuilding', () => { + vi.stubEnv('NODE_ENV', 'development') + const firstTree = createRoutes() + const secondTree = createRoutes() + const first = createTestRouter({ + routeTree: firstTree.routeTree, + history: history(), + }) + const second = createTestRouter({ + routeTree: secondTree.routeTree, + history: history(), + }) + for (const router of [first, second]) { + expect( + router.buildLocation({ to: '/items/$id', params: { id: 'one' } }).href, + ).toBe('/items/one') + } + expect(firstTree.item._branch).not.toBe(secondTree.item._branch) + expect(firstTree.item._pathCache).not.toBe(secondTree.item._pathCache) + + const previousResolve = first.resolvePathCache + const previousFallback = first['unmatchedPathCache'] + const previousBranch = firstTree.item._branch + const previousPlan = firstTree.item._pathCache + first.setRoutes(first.buildRouteTree()) + expect(first.resolvePathCache).not.toBe(previousResolve) + expect(first['unmatchedPathCache']).not.toBe(previousFallback) + expect(firstTree.item._branch).toBeUndefined() + expect(firstTree.item._pathCache).toBe(previousPlan) + expect( + first.buildLocation({ to: '/items/$id', params: { id: 'one' } }).href, + ).toBe('/items/one') + expect(firstTree.item._branch).not.toBe(previousBranch) + expect(firstTree.item._pathCache).toBe(previousPlan) +}) + +test('keeps more than 32 registered route templates warm', () => { + const root = new BaseRootRoute({}) + const routes = Array.from( + { length: 128 }, + (_, index) => + new BaseRoute({ + getParentRoute: () => root, + path: `/section-${index}/$id`, + }), + ) + const router = createTestRouter({ + routeTree: root.addChildren(routes), + history: history(), + }) + const decoder = vi.fn(pathUtils.compileDecodeCharMap(['@'])) + router.pathParamsDecoder = decoder + for (let round = 0; round < 2; round++) { + for (let index = 0; index < routes.length; index++) { + expect( + router.buildLocation({ + to: `/section-${index}/$id`, + params: { id: '@one' }, + }).pathname, + ).toBe(`/section-${index}/@one`) + } + expect(decoder).toHaveBeenCalledTimes(routes.length) + } +}) + +test('refreshes branches and path plans when an existing route is reparented', () => { + vi.stubEnv('NODE_ENV', 'development') + const root = new BaseRootRoute({}) + const left = new BaseRoute({ getParentRoute: () => root, path: '/left' }) + const right = new BaseRoute({ getParentRoute: () => root, path: '/right' }) + let parent: AnyRoute = left + const child = new BaseRoute({ + getParentRoute: () => parent, + path: '/child/$id', + }) + const router = createTestRouter({ + routeTree: root.addChildren([left.addChildren([child]), right]), + history: history(), + }) + expect( + router.buildLocation({ to: '/left/child/$id', params: { id: 'one two' } }) + .href, + ).toBe('/left/child/one%20two') + expect(child._branch).toEqual([root, left, child]) + const plan = child._pathCache + + parent = right + left.addChildren([]) + right.addChildren([child]) + router.setRoutes(router.buildRouteTree()) + expect(child._branch).toBeUndefined() + expect(child._pathCache).toBe(plan) + expect( + router.buildLocation({ to: '/right/child/$id', params: { id: 'one two' } }) + .href, + ).toBe('/right/child/one%20two') + expect(child._branch).toEqual([root, right, child]) + expect(child._pathCache).not.toBe(plan) +}) + +test('keeps the 128-result bound within each route', () => { + const { routeTree } = createRoutes() + const router = createTestRouter({ routeTree, history: history() }) + const decoder = vi.fn(pathUtils.compileDecodeCharMap(['@'])) + router.pathParamsDecoder = decoder + for (let id = 0; id <= 128; id++) { + expect( + router.buildLocation({ + to: '/items/$id', + params: { id: `@${id}` }, + }).pathname, + ).toBe(`/items/@${id}`) + } + decoder.mockClear() + expect( + router.buildLocation({ to: '/items/$id', params: { id: '@0' } }).pathname, + ).toBe('/items/@0') + expect(decoder).toHaveBeenCalledOnce() +}) + +test('isolates decoder and trailing-slash variants on the same server route', () => { + const { routeTree } = createRoutes() + const allowAt = createTestRouter({ + routeTree, + history: history(), + isServer: true, + trailingSlash: 'never', + pathParamsAllowedCharacters: ['@'], + }) + const allowPlus = createTestRouter({ + routeTree, + history: history(), + isServer: true, + trailingSlash: 'always', + pathParamsAllowedCharacters: ['+'], + }) + for (let round = 0; round < 3; round++) { + expect( + allowAt.buildLocation({ to: '/items/$id', params: { id: '@+' } }) + .pathname, + ).toBe('/items/@%2B') + expect( + allowPlus.buildLocation({ to: '/items/$id', params: { id: '@+' } }) + .pathname, + ).toBe('/items/%40+/') + } + allowPlus.pathParamsDecoder = allowAt.pathParamsDecoder + expect( + allowPlus.buildLocation({ to: '/items/$id', params: { id: '@+' } }) + .pathname, + ).toBe('/items/@%2B/') + expect( + allowAt.buildLocation({ to: '/items/$id', params: { id: '@+' } }).pathname, + ).toBe('/items/@%2B') +}) + +test('retains a bounded fallback for unregistered mask templates', () => { + const { routeTree } = createRoutes() + const router = createTestRouter({ routeTree, history: history() }) + const decoder = vi.fn(pathUtils.compileDecodeCharMap(['@'])) + router.pathParamsDecoder = decoder + const build = () => + router.buildLocation({ + to: '/items/$id', + params: { id: '@one' }, + mask: { to: '/pretty/$id', params: { id: '@one' } }, + }) + expect(build().maskedLocation?.pathname).toBe('/pretty/@one') + decoder.mockClear() + expect(build().maskedLocation?.pathname).toBe('/pretty/@one') + expect(decoder).not.toHaveBeenCalled() + + const templates = Array.from( + { length: 64 }, + (_, index) => `/unregistered-${index}/$id`, + ) + for (const to of templates) { + router.buildLocation({ + to, + params: { id: '@one' }, + }) + } + const cache = router['unmatchedPathCache'] + expect( + ['/pretty/$id', ...templates].filter((path) => cache.get(path)), + ).toHaveLength(32) + const evicted = templates.find((path) => !cache.get(path))! + expect(evicted).toBeDefined() + decoder.mockClear() + expect( + router.buildLocation({ to: evicted, params: { id: '@one' } }).pathname, + ).toBe(evicted.replace('$id', '@one')) + expect(decoder).toHaveBeenCalledOnce() +}) From e0b7e9fe4b3488be718cfbe6497c496ba920a777 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:51:40 +0200 Subject: [PATCH 18/19] test(router-core): add opt-in matching interpolation benchmarks Cover client/server matching with cold and Link-primed route plans, nested params, high-cardinality misses, optional params, and splats. Gate all additional cases behind TSR_LINK_PERF=1. Evaluate six matching-cache prototypes without retaining production changes. Eager variants regress repeated misses by24-64%. Read-through reuse improves warm matching microcases but adds65 gzip bytes; all26 application Link/SSR comparisons show no supported speedup, with one relative-Link slowdown. Real HTTP SSR ABBA means differ by only -0.41%, within observed variation. Restore production and all18 bundle metrics exactly to ec05cc6bec. Preserve detailed measurements and rejected prototypes in session artifacts and uncommitted LOG.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/matching-interpolation.bench.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 packages/router-core/tests/matching-interpolation.bench.ts diff --git a/packages/router-core/tests/matching-interpolation.bench.ts b/packages/router-core/tests/matching-interpolation.bench.ts new file mode 100644 index 00000000000..8932897ec26 --- /dev/null +++ b/packages/router-core/tests/matching-interpolation.bench.ts @@ -0,0 +1,123 @@ +import { bench, describe, expect } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' +import type { AnyRoute } from '../src' + +const cases = [ + { + name: 'nested required params', + segments: ['/orgs/$org', '/items/$id'], + paths: Array.from( + { length: 32 }, + (_, id) => `/orgs/team%20one/items/item%20${id}`, + ), + }, + { + name: 'non-overlapping params', + segments: ['/items/$id'], + paths: Array.from({ length: 256 }, (_, id) => `/items/item%20${id}`), + misses: true, + }, + { + name: 'missing optional params', + segments: ['/items/{-$lang}/$id'], + paths: Array.from({ length: 32 }, (_, id) => `/items/item%20${id}`), + }, + { + name: 'present optional params', + segments: ['/items/{-$lang}/$id'], + paths: Array.from({ length: 32 }, (_, id) => `/items/en/item%20${id}`), + }, + { + name: 'affixed splats', + segments: ['/files/prefix{$}.txt'], + paths: Array.from( + { length: 32 }, + (_, id) => `/files/prefixdocs/file%20${id}.txt`, + ), + }, +] + +// Opt in explicitly; these extra cases are not part of default CI benchmarks. +if (process.env.TSR_LINK_PERF === '1') { + for (const server of [false, true]) { + for (const primeLinks of [false, true]) { + describe(`matching interpolation (server: ${server}, Link-primed: ${primeLinks})`, () => { + for (const scenario of cases) { + const root = new BaseRootRoute({}) + let parent: AnyRoute = root + for (const path of scenario.segments) { + const parentRoute = parent + const route = new BaseRoute({ + getParentRoute: () => parentRoute, + path, + }) + parent.addChildren([route]) + parent = route + } + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: root, + history, + isServer: server, + scrollRestoration: false, + }) + history.destroy() + const options = { _controller: new AbortController() } + + for (const path of scenario.paths) { + router.getMatchedRoutes(path) + } + if (primeLinks) { + const paths = scenario.misses + ? scenario.paths + .slice(0, 32) + .map((path) => path.replace('item%20', 'cached%20')) + : scenario.paths + for (const path of paths) { + const [routes, params] = router.getMatchedRoutes(path) + for (const route of routes) { + const to: string = route.fullPath + if (to.includes('$')) { + router.buildLocation({ to, params }) + } + } + } + } + + let expected = 0 + for (const path of scenario.paths) { + const matches = router.matchRoutes(path, {}, options) + expect(matches.at(-1)?.routeId).toBe(parent.id) + for (const match of matches) { + expect(match.paramsError).toBeUndefined() + expected += + match.id.length + Object.keys(match._strictParams).length + } + } + let checksum = 0 + bench( + scenario.name, + () => { + let length = 0 + for (const path of scenario.paths) { + for (const match of router.matchRoutes(path, {}, options)) { + length += + match.id.length + Object.keys(match._strictParams).length + } + } + checksum = length + }, + { + time: 1000, + warmupTime: 300, + throws: true, + teardown: () => expect(checksum).toBe(expected), + }, + ) + } + }) + } + } +} From b58ebfbb66e2505991e067c745ab40685607874e Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:10:21 +0200 Subject: [PATCH 19/19] fix(vue-router): scope functional-render subscriptions Functional Vue renders have an instance but no active effect scope. Reuse the existing Vue-store hook inside a render-owned scope, disposing subscriptions before rerender and unmount. Scope public useLinkProps effects as well; preserve setup behavior, equality and readonly semantics. The unchanged navigation-churn fixture previously left 600 subscriptions active after unmount, including on the CodSpeed base. The fix leaves zero. Four isolated Node 24.8.0 replicas against e0b7e9f reduced sampled JS allocations 34.2% (76.61 to 50.43 MB) for navigation churn and 22.5% (28.70 to 22.24 MB) for interrupted navigation. Mounted post-GC heap growth fell 78.9% and 13.9%; workload CPU fell 43.4% and 12.7%. These are local JS/CPU measurements, not CodSpeed native peaks. Balanced Vue Link comparisons remained within module-order noise (-0.14% mean CPU). Vue Router adds 110/135 gzip bytes and Vue Start adds 125 bytes; React and Solid bundle output is unchanged. Add failing-before lifecycle regressions for useMatch and functional useLinkProps. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/three-hornets-bathe.md | 5 + packages/vue-router/src/Match.tsx | 2 +- packages/vue-router/src/Matches.tsx | 2 +- packages/vue-router/src/Scripts.tsx | 2 +- packages/vue-router/src/headContentUtils.tsx | 2 +- packages/vue-router/src/link.tsx | 8 +- packages/vue-router/src/not-found.tsx | 2 +- packages/vue-router/src/useCanGoBack.ts | 2 +- packages/vue-router/src/useLocation.tsx | 2 +- packages/vue-router/src/useMatch.tsx | 2 +- packages/vue-router/src/useRouterState.tsx | 2 +- packages/vue-router/src/useStore.ts | 40 ++++++ .../vue-router/tests/useLinkProps.test.tsx | 121 ++++++++++++++++++ packages/vue-router/tests/useMatch.test.tsx | 83 ++++++++++++ 14 files changed, 264 insertions(+), 11 deletions(-) create mode 100644 .changeset/three-hornets-bathe.md create mode 100644 packages/vue-router/src/useStore.ts create mode 100644 packages/vue-router/tests/useLinkProps.test.tsx diff --git a/.changeset/three-hornets-bathe.md b/.changeset/three-hornets-bathe.md new file mode 100644 index 00000000000..f0d453d7474 --- /dev/null +++ b/.changeset/three-hornets-bathe.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-router': patch +--- + +Clean up store subscriptions and Link preload effects created during functional component renders before rerendering or unmounting. diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 56530bb38e1..98fbe713410 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -1,7 +1,7 @@ import * as Vue from 'vue' import { isNotFound, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/vue-store' +import { useStore } from './useStore' import { CatchBoundary } from './CatchBoundary' import { ClientOnly } from './ClientOnly' import { useRouter } from './useRouter' diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index 7edd5c7593e..91a340e4206 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -1,6 +1,6 @@ import * as Vue from 'vue' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/vue-store' +import { useStore } from './useStore' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import { useTransitionerSetup } from './Transitioner' diff --git a/packages/vue-router/src/Scripts.tsx b/packages/vue-router/src/Scripts.tsx index 4c0d8feed06..f3e446090c3 100644 --- a/packages/vue-router/src/Scripts.tsx +++ b/packages/vue-router/src/Scripts.tsx @@ -1,7 +1,7 @@ import * as Vue from 'vue' import { _getAssetMatches } from '@tanstack/router-core' -import { useStore } from '@tanstack/vue-store' import { isServer } from '@tanstack/router-core/isServer' +import { useStore } from './useStore' import { Asset } from './Asset' import { useRouter } from './useRouter' import type { RouterManagedTag } from '@tanstack/router-core' diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx index adfc156c720..5e020ce6634 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -7,7 +7,7 @@ import { getScriptPreloadAttrs, resolveManifestCssLink, } from '@tanstack/router-core' -import { useStore } from '@tanstack/vue-store' +import { useStore } from './useStore' import { useRouter } from './useRouter' import type { AssetCrossOriginConfig, diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 9a90ef449d3..6ebfb078b98 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -9,7 +9,7 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/vue-store' +import { getRenderScope, useStore } from './useStore' import { useRouter } from './useRouter' import { useIntersectionObserver } from './utils' @@ -98,7 +98,11 @@ export function useLinkProps< >( options: UseLinkPropsOptions, ): LinkHTMLAttributes { - return useLinkPropsImpl(() => options as AnyLinkPropsOptions) + const scope = getRenderScope() + const getOptions = () => options as AnyLinkPropsOptions + return scope + ? scope.run(() => useLinkPropsImpl(getOptions))! + : useLinkPropsImpl(getOptions) } function useLinkPropsImpl( diff --git a/packages/vue-router/src/not-found.tsx b/packages/vue-router/src/not-found.tsx index 05d9ab4c883..ae37cd7e5fe 100644 --- a/packages/vue-router/src/not-found.tsx +++ b/packages/vue-router/src/not-found.tsx @@ -1,6 +1,6 @@ import * as Vue from 'vue' import { isNotFound } from '@tanstack/router-core' -import { useStore } from '@tanstack/vue-store' +import { useStore } from './useStore' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import type { ErrorComponentProps, NotFoundError } from '@tanstack/router-core' diff --git a/packages/vue-router/src/useCanGoBack.ts b/packages/vue-router/src/useCanGoBack.ts index 21495849790..0b363f09233 100644 --- a/packages/vue-router/src/useCanGoBack.ts +++ b/packages/vue-router/src/useCanGoBack.ts @@ -1,4 +1,4 @@ -import { useStore } from '@tanstack/vue-store' +import { useStore } from './useStore' import { useRouter } from './useRouter' export function useCanGoBack() { diff --git a/packages/vue-router/src/useLocation.tsx b/packages/vue-router/src/useLocation.tsx index 2d9e4314a24..b21332fe5bd 100644 --- a/packages/vue-router/src/useLocation.tsx +++ b/packages/vue-router/src/useLocation.tsx @@ -1,4 +1,4 @@ -import { useStore } from '@tanstack/vue-store' +import { useStore } from './useStore' import { useRouter } from './useRouter' import type { AnyRouter, diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index 927962553d7..321a8b49ca7 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -1,7 +1,7 @@ import * as Vue from 'vue' import { invariant } from '@tanstack/router-core' -import { useStore } from '@tanstack/vue-store' import { isServer } from '@tanstack/router-core/isServer' +import { useStore } from './useStore' import { routeIdContext } from './matchContext' import { useRouter } from './useRouter' import type { diff --git a/packages/vue-router/src/useRouterState.tsx b/packages/vue-router/src/useRouterState.tsx index 7d3d536f57d..662c09f238d 100644 --- a/packages/vue-router/src/useRouterState.tsx +++ b/packages/vue-router/src/useRouterState.tsx @@ -1,6 +1,6 @@ import * as Vue from 'vue' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/vue-store' +import { useStore } from './useStore' import { useRouter } from './useRouter' import type { AnyRouter, diff --git a/packages/vue-router/src/useStore.ts b/packages/vue-router/src/useStore.ts new file mode 100644 index 00000000000..4c8200eb9eb --- /dev/null +++ b/packages/vue-router/src/useStore.ts @@ -0,0 +1,40 @@ +import * as Vue from 'vue' +import { useStore as useStoreBase } from '@tanstack/vue-store' + +type RenderScope = { scope?: Vue.EffectScope } +const renderScopes = new WeakMap() + +export function getRenderScope() { + const instance = Vue.getCurrentInstance() + if (Vue.getCurrentScope() || !instance) { + return undefined + } + + // Functional renders have an instance but no active effect scope. + // Replace their subscriptions on each render instead of accumulating watchers. + let entry = renderScopes.get(instance) + if (!entry) { + const owner: RenderScope = {} + entry = owner + renderScopes.set(instance, owner) + const reset = () => { + const scope = owner.scope + owner.scope = undefined + scope?.stop() + } + Vue.onBeforeUpdate(reset, instance) + Vue.onBeforeUnmount(() => { + reset() + renderScopes.delete(instance) + }, instance) + } + + return (entry.scope ??= Vue.effectScope(true)) +} + +export const useStore: typeof useStoreBase = (store, selector, options) => { + const scope = getRenderScope() + return scope + ? scope.run(() => useStoreBase(store, selector, options))! + : useStoreBase(store, selector, options) +} diff --git a/packages/vue-router/tests/useLinkProps.test.tsx b/packages/vue-router/tests/useLinkProps.test.tsx new file mode 100644 index 00000000000..0e8858ca2a1 --- /dev/null +++ b/packages/vue-router/tests/useLinkProps.test.tsx @@ -0,0 +1,121 @@ +import * as Vue from 'vue' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { + RouterContextProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useLinkProps, +} from '../src' +import { getIntersectionObserverMock } from './utils' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +test.each(['viewport', 'render'] as const)( + 'disposes functional useLinkProps subscriptions and %s effects', + async (preload) => { + const observe = vi.fn() + const disconnect = vi.fn() + vi.stubGlobal( + 'IntersectionObserver', + getIntersectionObserverMock({ observe, disconnect }), + ) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const rootRoute = createRootRoute() + const router = createRouter({ + routeTree: rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: '/' }), + createRoute({ getParentRoute: () => rootRoute, path: '/target' }), + ]), + history, + isServer: false, + }) + await router.load() + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(undefined) + const preloadRoute = vi + .spyOn(router, 'preloadRoute') + .mockResolvedValue(undefined) + const store = router.stores.location + const subscribe = store.subscribe.bind(store) + const cleanups: Array<() => void> = [] + let active = 0 + const spy = vi.spyOn(store, 'subscribe').mockImplementation((listener) => { + const subscription = subscribe(listener) + active++ + let stopped = false + const unsubscribe = () => { + if (!stopped) { + stopped = true + active-- + subscription.unsubscribe() + } + } + cleanups.push(unsubscribe) + return { ...subscription, unsubscribe } + }) + const options = Vue.reactive({ + to: 'https://example.com', + preload, + title: 'first', + }) + const FunctionalLink = () => + Vue.h('a', { ...Vue.unref(useLinkProps(options)) }, 'Functional link') + const view = render( + + + , + ) + try { + const link = view.getByRole('link', { name: 'Functional link' }) + expect(link).toHaveAttribute('href', 'https://example.com') + expect(active).toBe(0) + expect(observe).not.toHaveBeenCalled() + + options.to = '/target' + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/target') + expect(active).toBe(1) + + for (const title of ['second', 'third']) { + options.title = title + await Vue.nextTick() + expect(link).toHaveAttribute('title', title) + expect(active).toBe(1) + } + store.set(router.buildLocation({ to: '/target' })) + await Vue.nextTick() + expect(link).toHaveAttribute('data-status', 'active') + expect(active).toBe(1) + await fireEvent.click(link) + expect(navigate).toHaveBeenCalledOnce() + + if (preload === 'viewport') { + expect(observe.mock.calls.length - disconnect.mock.calls.length).toBe(1) + } else { + expect(preloadRoute).toHaveBeenCalled() + } + view.unmount() + expect(active).toBe(0) + expect(disconnect).toHaveBeenCalledTimes(observe.mock.calls.length) + preloadRoute.mockClear() + options.to = '/' + await Vue.nextTick() + expect(preloadRoute).not.toHaveBeenCalled() + expect(active).toBe(0) + } finally { + view.unmount() + for (const unsubscribe of cleanups) { + unsubscribe() + } + spy.mockRestore() + navigate.mockRestore() + preloadRoute.mockRestore() + history.destroy() + } + }, +) diff --git a/packages/vue-router/tests/useMatch.test.tsx b/packages/vue-router/tests/useMatch.test.tsx index 7a73cdbea78..5daaa0e5773 100644 --- a/packages/vue-router/tests/useMatch.test.tsx +++ b/packages/vue-router/tests/useMatch.test.tsx @@ -10,6 +10,7 @@ import * as Vue from 'vue' import { Link, Outlet, + RouterContextProvider, RouterProvider, createMemoryHistory, createRootRoute, @@ -25,6 +26,88 @@ afterEach(() => { }) describe('useMatch', () => { + test.each([1, 2])( + 'disposes %s functional-render subscriptions before rerender and unmount', + async (count) => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: createRootRoute(), + history, + isServer: false, + }) + const initial = router.matchRoutes('/', {})[0]! + router.stores.setMatches([{ ...initial, updatedAt: 1 }]) + const store = router.stores.getMatchStore('__root__') + const subscribe = store.subscribe.bind(store) + const cleanups: Array<() => void> = [] + let active = 0 + const spy = vi + .spyOn(store, 'subscribe') + .mockImplementation((listener) => { + const subscription = subscribe(listener) + active++ + let stopped = false + const unsubscribe = () => { + if (!stopped) { + stopped = true + active-- + subscription.unsubscribe() + } + } + cleanups.push(unsubscribe) + return { ...subscription, unsubscribe } + }) + const visible = Vue.ref(true) + const Probe = () => { + const revisions = visible.value + ? Array.from({ length: count }, () => + useMatch({ + from: '__root__', + select: (match) => match.updatedAt, + }), + ) + : [] + return ( +
+ {revisions.map((revision) => revision.value).join(',') || 'hidden'} +
+ ) + } + const view = render( + + + , + ) + try { + expect(active).toBe(count) + for (const revision of [2, 3, 4]) { + router.stores.setMatches([{ ...initial, updatedAt: revision }]) + await Vue.nextTick() + expect( + view.getByText(Array(count).fill(revision).join(',')), + ).toBeInTheDocument() + expect(active).toBe(count) + } + visible.value = false + await Vue.nextTick() + expect(view.getByText('hidden')).toBeInTheDocument() + expect(active).toBe(0) + visible.value = true + await Vue.nextTick() + expect(active).toBe(count) + view.unmount() + expect(active).toBe(0) + } finally { + view.unmount() + for (const cleanupSubscription of cleanups) { + cleanupSubscription() + } + spy.mockRestore() + history.destroy() + } + }, + ) + function setup({ RootComponent, history,