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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/benchmarks/client-nav/README.md b/benchmarks/client-nav/README.md index 6179c7f0b8c..3cf38f87dee 100644 --- a/benchmarks/client-nav/README.md +++ b/benchmarks/client-nav/README.md @@ -115,3 +115,100 @@ 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. 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 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: + +```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. + +### 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/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..086fadd1619 --- /dev/null +++ b/benchmarks/client-nav/link-performance/client.bench.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, bench, describe } from 'vitest' +import { LINK_CASES } from './cases' +import { createClientScenario, samplingOptions } from './scenario' +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, () => { + const scenario = createClientScenario(app, id) + function finish() { + try { + scenario.check() + } finally { + scenario.teardown() + } + } + beforeEach(scenario.setup) + afterEach(finish) + + 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 new file mode 100644 index 00000000000..3f2624677f2 --- /dev/null +++ b/benchmarks/client-nav/link-performance/config.test.ts @@ -0,0 +1,57 @@ +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}`) + 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 new file mode 100644 index 00000000000..ef5e4f29ca7 --- /dev/null +++ b/benchmarks/client-nav/link-performance/config.ts @@ -0,0 +1,71 @@ +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: process.env.VITEST ? undefined : 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: { + platform: 'node', + external: [ + 'node:module', + 'module', + /^react(?:\/|$)/, + /^react-dom(?:\/|$)/, + ], + output: { entryFileNames: 'app.js' }, + }, + }, + test: { + name: `react-link-performance-${target}`, + watch: false, + environment: server ? 'node' : 'jsdom', + setupFiles: server ? [] : ['../vitest.setup.ts'], + server: { + deps: { + external: [/\/link-performance\/dist\//], + }, + }, + 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..548a92c8725 --- /dev/null +++ b/benchmarks/client-nav/link-performance/project.json @@ -0,0 +1,82 @@ +{ + "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: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": [], + "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/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/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..8315b85b8b3 --- /dev/null +++ b/benchmarks/client-nav/link-performance/src/workload.tsx @@ -0,0 +1,506 @@ +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..2a4b4312924 --- /dev/null +++ b/benchmarks/client-nav/link-performance/ssr.bench.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, bench, describe } from 'vitest' +import { LINK_CASES } from './cases' +import { createSsrScenario, samplingOptions } from './scenario' +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, () => { + const scenario = createSsrScenario(app, id) + function finish() { + try { + scenario.check() + } finally { + scenario.teardown() + } + } + beforeEach(scenario.setup) + afterEach(finish) + + 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/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..f8454c819dd --- /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: ['*.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 } 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. diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c6652354c6a..69e810a8329 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, { @@ -439,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 }), @@ -523,14 +512,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,32 +532,10 @@ export function useLinkProps< compareLinkState, ) - // Get the active props - const resolvedActiveProps: React.HTMLAttributes = isActive - ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT) - : STATIC_EMPTY_OBJECT - - // Get the inactive props - const resolvedInactiveProps: React.HTMLAttributes = - isActive - ? STATIC_EMPTY_OBJECT - : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT) - - const resolvedClassName = [ - className, - resolvedActiveProps.className, - resolvedInactiveProps.className, - ] - .filter(Boolean) - .join(' ') - - const resolvedStyle = (style || - resolvedActiveProps.style || - resolvedInactiveProps.style) && { - ...style, - ...resolvedActiveProps.style, - ...resolvedInactiveProps.style, - } + 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) @@ -707,9 +674,6 @@ export function useLinkProps< return { ...propsSafeToSpread, - ...resolvedActiveProps, - ...resolvedInactiveProps, - href, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers(onClick, handleClick), onBlur: composeHandlers(onBlur, handleLeave), @@ -717,10 +681,17 @@ 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, - ...(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/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/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/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/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') +}) diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index d302ee13ab7..1b564382ec8 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -2,7 +2,6 @@ import { isServer } from '@tanstack/router-core/isServer' import { last } from './utils' import { SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, parseSegment, @@ -209,38 +208,27 @@ 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 -} +export type InterpolationPlan = [ + keys: Array, + paths: SieveCache, + decoder: ((encoded: string) => string) | undefined, + path: string, +] function encodeParam( key: string, - params: InterpolatePathOptions['params'], - decoder: InterpolatePathOptions['decoder'], -): any { - const value = params[key] - if (typeof value !== 'string') return value + value: unknown, + decoder: ((encoded: string) => string) | undefined, +): string { + if (typeof value !== 'string') { + return '' + (value ?? undefined) + } 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 @@ -254,31 +242,26 @@ 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({ - 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 { - // 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 (isServer ?? rest.server) { +export function interpolatePath( + path: string | undefined, + params: Record, + decoder?: (encoded: string) => string, + usedParams?: Record, + keys?: Array, + server?: boolean, + metadata?: { isMissingParams: boolean }, +): string { + if (!path?.includes('$')) { + return path || '/' + } + + 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. @@ -289,65 +272,66 @@ 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) { - 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) - 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 (metadata && (splat ? !value : !(key in params))) { + metadata.isMissingParams = true + } + 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] @@ -356,73 +340,60 @@ export function interpolatePath({ continue } + 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 (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) - - // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. - if (!splat) { - 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 - if (prefix || suffix) { - joined += '/' + prefix + suffix + if (usedParams) { + usedParams[key] = paramValue + // TODO: Deprecate * + 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 } - continue + paramValue = '' } - - 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 + } 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 } - 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 - } + joined += + '/' + + path.substring(start, prefixEnd) + + encodeParam(key, paramValue, decoder) + + path.substring(suffixStart, end) } - if (path.endsWith('/')) joined += '/' - - const interpolatedPath = joined || '/' + if (path.endsWith('/')) { + joined += '/' + } - return { usedParams, interpolatedPath, isMissingParams } + 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/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 e5b6255c63b..76a706b7d2c 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, @@ -50,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, @@ -992,6 +994,13 @@ type LightweightRouteMatchCacheEntry = [ result: LightweightRouteMatchResult, ] +/** Indexes and caches rebuilt together when the route tree changes. */ +type RouteTreeCaches = + ProcessRouteTreeResult & { + resolvePathCache: SieveCache + unmatchedPathCache: SieveCache + } + export type CreateRouterFn = < TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption = 'never', @@ -1021,8 +1030,7 @@ declare global { var __TSR_CACHE__: | { routeTree: AnyRoute - processRouteTreeResult: ProcessRouteTreeResult - resolvePathCache: SieveCache + processRouteTreeResult: RouteTreeCaches } | undefined } @@ -1132,7 +1140,7 @@ export class RouterCore< routesByPath!: RoutesByPath processedTree!: ProcessedTree resolvePathCache!: SieveCache - private routeBranchCache = new WeakMap>() + private unmatchedPathCache!: SieveCache private lightweightCache = new WeakMap< ParsedLocation, LightweightRouteMatchCacheEntry @@ -1251,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) && @@ -1259,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 ( @@ -1273,7 +1279,6 @@ export class RouterCore< globalThis.__TSR_CACHE__ = { routeTree: this.routeTree, processRouteTreeResult: processRouteTreeResult as any, - resolvePathCache: this.resolvePathCache, } } } @@ -1339,7 +1344,7 @@ export class RouterCore< ) } - buildRouteTree = () => { + buildRouteTree = (): RouteTreeCaches => { const result = processRouteTree( this.routeTree, this.options.caseSensitive, @@ -1353,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 @@ -1502,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, @@ -1638,12 +1633,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 + ? interpolatePath( + route.fullPath, + rawParams, + this.pathParamsDecoder, + usedParams, + undefined, + this.isServer, + ) + : interpolatePath( + 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 @@ -1854,6 +1860,76 @@ export class RouterCore< return result } + private interpolatePath( + path: string, + params: Record, + route?: AnyRoute, + ): string { + const decoder = this.pathParamsDecoder + let plan = route ? route._pathCache : this.unmatchedPathCache.get(path) + let interpolated: string | undefined + if (!plan || plan[2] !== decoder || plan[3] !== path) { + const keys: Array = [] + interpolated = + isServer === undefined + ? interpolatePath( + path, + params, + decoder, + undefined, + keys, + this.isServer, + ) + : interpolatePath(path, params, decoder, undefined, keys) + plan = [keys, createSieveCache(128), decoder, path] + if (route) { + route._pathCache = plan + } else { + this.unmatchedPathCache.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 + ? interpolatePath( + path, + params, + decoder, + undefined, + undefined, + this.isServer, + ) + : interpolatePath(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 + ? interpolatePath( + path, + params, + decoder, + undefined, + undefined, + this.isServer, + ) + : interpolatePath(path, params, decoder)), + ) + return interpolated + } + /** * Build the next ParsedLocation from navigation options without committing. * Resolves `to`/`from`, params/search/hash/state, applies search validation @@ -1939,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. @@ -1964,7 +2040,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)) @@ -1983,12 +2059,9 @@ export class RouterCore< ? // Keep path params uninterpolated for matchRoute/template matching. nextTo : decodePath( - interpolatePath({ - path: nextTo, - params: nextParams, - decoder: this.pathParamsDecoder, - server: this.isServer, - }).interpolatedPath, + nextTo.includes('$') + ? this.interpolatePath(nextTo, nextParams, destRoute) + : nextTo, ).path if ( @@ -2036,6 +2109,7 @@ export class RouterCore< dest, destRoutes, opts._includeValidateSearch, + isServer ?? this.isServer, ) // Replace the equal deep @@ -2125,7 +2199,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 @@ -2755,19 +2829,38 @@ 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, destRoutes: ReadonlyArray, includeValidateSearch: boolean | undefined, + server: boolean, ) { - 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 +2882,7 @@ function applySearchMiddleware( ) : result } - middlewares.push(legacyMiddleware) + ;(middlewares ||= []).push(legacyMiddleware) } const routeValidateSearch = routeOptions.validateSearch @@ -2813,9 +2906,17 @@ function applySearchMiddleware( return result } - middlewares.push(validate) + ;(middlewares ||= []).push(validate) + } + } + + if (!middlewares?.length) { + if (!dest.search) { + return !(isServer ?? server) && !hasKeys(search) ? search : {} } + return dest.search === true ? search : functionalUpdate(dest.search, search) } + const middlewareList = middlewares const applyNext = ( index: number, @@ -2823,7 +2924,7 @@ function applySearchMiddleware( 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 +2949,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) @@ -2874,20 +2979,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 - } - const next = Object.assign(Object.create(null), base) - return Object.assign(next, functionalUpdate(spec as any, 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 edb763fa03a..0beb779333d 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -1,5 +1,8 @@ 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, @@ -24,6 +27,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, 'interpolatePath') + 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('shares interpolation on reused routes 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).not.toHaveBeenCalled() + + 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({}) @@ -234,6 +304,76 @@ 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({ + 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 +1793,77 @@ 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({ + 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({ 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), + }, + ) + } + }) + } + } +} 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 new file mode 100644 index 00000000000..8f4b6c1e5cb --- /dev/null +++ b/packages/router-core/tests/path-interpolation.bench.ts @@ -0,0 +1,259 @@ +import { bench, describe, expect } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { compileDecodeCharMap, interpolatePath } from '../src/path' +import { createTestRouter } from './routerTestUtils' +import type { PathInterpolationTestOptions } from './routerTestUtils' + +const scenarios: Array<{ + name: string + inputs: Array + register?: boolean +}> = [ + { + 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: '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' }, + })), + }, + { + 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: {} } + } + }), + }, + { + 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: '' } } + } + }), + }, + { + 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(['@', '+']) +for (const { inputs } of scenarios) { + for (const input of inputs) { + input.decoder = decoder + input.server = false + } +} +scenarios.push( + ...scenarios.map(({ name, inputs, register }) => ({ + name: `server ${name}`, + inputs: inputs.map((input) => ({ ...input, server: true })), + register, + })), +) + +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: register ? root.addChildren([...routes.values()]) : root, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: inputs[0]?.server, + scrollRestoration: false, + }) + router.pathParamsDecoder = decoder + router.history.destroy() + const calls = inputs + .filter((input) => input.path?.includes('$')) + .map((input) => ({ + path: input.path || '/', + params: input.params, + route: register ? routes.get(input.path || '/') : undefined, + expected: interpolatePath( + input.path, + input.params, + input.decoder, + undefined, + undefined, + input.server, + ), + })) + let checksum = 0 + const expected = inputs.reduce( + (sum, input) => + sum + + interpolatePath( + input.path, + input.params, + input.decoder, + undefined, + undefined, + input.server, + ).length, + 0, + ) + const cachedExpected = calls.reduce((sum, call) => { + 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, call.route)).toBe( + call.expected, + ) + } + + bench( + 'shared interpolation batch', + () => { + let length = 0 + for (const call of calls) { + length += router['interpolatePath']( + call.path, + call.params, + call.route, + ).length + } + checksum = length + }, + { + time: 1500, + warmupTime: 500, + throws: true, + teardown: () => { + expect(checksum).toBe(cachedExpected) + }, + }, + ) + + bench( + 'pathname-only interpolation batch', + () => { + let length = 0 + for (const input of inputs) { + length += interpolatePath( + 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( + 'metadata-enabled interpolation batch', + () => { + let length = 0 + for (const input of inputs) { + length += interpolatePath( + input.path, + input.params, + input.decoder, + Object.create(null), + undefined, + input.server, + { isMissingParams: false }, + ).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 8e4f5ed1db9..d1326acd0f8 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -16,8 +16,396 @@ 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])( + 'shared pathname interpolation (server: %s)', + (server) => { + it.each([ + { 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' }, + 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() + 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('refreshes each cached template when its decoder changes', () => { + const interpolate = createPathInterpolator() + const allowAt = compileDecodeCharMap(['@']) + const allowPlus = compileDecodeCharMap(['+']) + 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(prefix + encoded) + } + } + }) + + 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', + ) + }) + + it('tracks optional params that were absent when the template was first used', () => { + const interpolate = createPathInterpolator() + const path = '/posts/{-$category}/$id' + const inputs = [ + { 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, expected } of [...inputs, ...inputs]) { + const options = { path, params, server } + expect(interpolate(options)).toBe(expected) + } + }) + + 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('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([ + { + 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( + interpolatePath( + 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( + template, + params, + undefined, + undefined, + undefined, + server, + ), + ).toBe(path) + }, + ) + + 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 }) => { + 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) + }, + ) + + 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(`/files/prefix${_splat}suffix`) + } + }) + + 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}`) + } + }) + }, +) + describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( 'removeTrailingSlash with basepath $basepath', ({ basepath }) => { @@ -295,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([ { @@ -401,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) }) }) @@ -437,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) }, ) @@ -481,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) }) }) @@ -548,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) }) }) @@ -597,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) }) }) @@ -649,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) }) }) @@ -676,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/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() +}) diff --git a/packages/router-core/tests/routerTestUtils.ts b/packages/router-core/tests/routerTestUtils.ts index 413b810b95a..8e1b6ee533a 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,27 @@ 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({}), + history: createMemoryHistory({ initialEntries: ['/'] }), + scrollRestoration: false, + }) + router.history.destroy() + return (options: PathInterpolationTestOptions): string => { + router.isServer = options.server ?? false + router.pathParamsDecoder = options.decoder + return router['interpolatePath'](options.path || '/', options.params) + } +} + /** Materialize the request-local server result as the HTTP response users see. */ export function loadServerResponse( router: AnyRouter, 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() + } + }, +) 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/src/link.tsx b/packages/solid-router/src/link.tsx index 1f70fa8af93..e74801f3267 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,30 @@ 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, ...base, - ...(hasKeys(style) ? { style } : undefined), + ...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), } 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..5f7aacdbb12 --- /dev/null +++ b/packages/solid-router/tests/link-style.test.tsx @@ -0,0 +1,218 @@ +import * as Solid from 'solid-js' +import { createStore } from 'solid-js/store' +import { afterEach, expect, test, vi } from 'vitest' +import { + cleanup, + fireEvent, + 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.each([true, false])( + '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()) + const unusedClick = vi.fn() + const selectedRef = vi.fn() + const stateProps = { + ref: selectedRef, + title: 'state title', + onClick: selectedClick, + class: 'state-class', + style: { color: 'blue' }, + href: '/state-href', + target: '_self', + disabled: true, + } + 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).toHaveAttribute('href', active ? '/target' : '/') + expect(link).toHaveAttribute('target', '_blank') + expect(link).not.toHaveAttribute('disabled') + 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(() => ( + ({ 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', + })) + 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/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/solid-router/tests/server/link-style.test.tsx b/packages/solid-router/tests/server/link-style.test.tsx new file mode 100644 index 00000000000..4ce962cde03 --- /dev/null +++ b/packages/solid-router/tests/server/link-style.test.tsx @@ -0,0 +1,109 @@ +import { JSDOM } from 'jsdom' +import { expect, test, vi } from 'vitest' +import { + Link, + createRootRoute, + createRoute, + createRouter, + useLinkProps, +} from '../../src' +import { + RouterServer, + createRequestHandler, + renderRouterToString, +} from '../../src/ssr/server' + +test('preserves routing options, 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, + 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, + inactiveProps: unused, + }) + const inactiveProps = useLinkProps({ + to: '/other', + target: '_blank', + activeProps: unused, + inactiveProps: inactive, + }) + resolvedActiveRef = activeProps.ref + resolvedInactiveRef = inactiveProps.ref + return ( + <> + 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[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([]) + expect(active).toHaveBeenCalled() + expect(unused).not.toHaveBeenCalled() + } finally { + dom.window.close() + } +}) 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 4c2a04e4894..6ebfb078b98 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, @@ -10,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' @@ -99,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( @@ -142,20 +145,17 @@ function useLinkPropsImpl( router, ) - const { - resolvedActiveProps, - resolvedInactiveProps, - resolvedClassName, - resolvedStyle, - } = resolveStyleProps(options, isActive) + const { resolvedProps, resolvedClass, resolvedStyle } = resolveStyleProps( + options, + isActive, + ) const result = combineResultProps({ href, options, isActive, - resolvedActiveProps, - resolvedInactiveProps, - resolvedClassName, + resolvedProps, + resolvedClass, resolvedStyle, }) @@ -411,21 +411,16 @@ function useLinkPropsImpl( return getExternalLinkProps(options, router, ref, staticEventHandlers) } - const { - resolvedActiveProps, - resolvedInactiveProps, - resolvedClassName, - resolvedStyle, - } = resolvedStyleProps.value + const { resolvedProps, resolvedClass, resolvedStyle } = + resolvedStyleProps.value return combineResultProps({ href: href.value, options, ref, staticEventHandlers, isActive: isActive.value, - resolvedActiveProps, - resolvedInactiveProps, - resolvedClassName, + resolvedProps, + resolvedClass, resolvedStyle, }) }) @@ -435,59 +430,48 @@ 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 resolvedClass = 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, - resolvedClassName, + resolvedProps, + resolvedClass, resolvedStyle, } } +const STATIC_ACTIVE_PROPS = { class: 'active' } +const EMPTY_OBJECT = {} + function combineResultProps({ href, options, isActive, - resolvedActiveProps, - resolvedInactiveProps, - resolvedClassName, + resolvedProps, + resolvedClass, resolvedStyle, ref, staticEventHandlers, @@ -496,9 +480,8 @@ function combineResultProps({ href: string | undefined options: AnyLinkPropsOptions isActive: boolean - resolvedActiveProps: StyledProps - resolvedInactiveProps: StyledProps - resolvedClassName?: string + resolvedProps: StyledProps + resolvedClass?: StyledProps['class'] resolvedStyle?: Record ref?: Vue.VNodeRef | undefined staticEventHandlers?: LinkEventHandlers @@ -516,8 +499,8 @@ function combineResultProps({ result.style = resolvedStyle } - if (resolvedClassName) { - result.class = resolvedClassName + if (resolvedClass) { + result.class = resolvedClass } if (options.disabled) { @@ -530,15 +513,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 +650,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) { @@ -938,8 +904,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/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/link-style-ssr.test.tsx b/packages/vue-router/tests/link-style-ssr.test.tsx new file mode 100644 index 00000000000..79af1f21647 --- /dev/null +++ b/packages/vue-router/tests/link-style-ssr.test.tsx @@ -0,0 +1,82 @@ +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', [{ 'active-object': true, hidden: false }]], + 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: true }, '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]!.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') + 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..865d7cdd402 --- /dev/null +++ b/packages/vue-router/tests/link-style.test.tsx @@ -0,0 +1,242 @@ +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, + useLinkProps, +} 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('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(() => + 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')) +}) 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?.()) }, 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,