diff --git a/benchmarks/client-nav/FEATURES.md b/benchmarks/client-nav/FEATURES.md new file mode 100644 index 0000000000..7e9d6d017c --- /dev/null +++ b/benchmarks/client-nav/FEATURES.md @@ -0,0 +1,51 @@ +# Client CPU Benchmark Feature Inventory + +This document records the client-side router features considered for CPU benchmark coverage while expanding `@benchmarks/client-nav`. It is not an implementation plan. Scenario implementation plans live in `plan/`. + +## Current Coverage + +The current `benchmarks/client-nav` benchmark is one mixed navigation loop per framework. It already exercises basic client navigation, `Link` clicks, imperative `router.navigate`, route matching, params parsing and stringifying, search validation, search middleware, one loader with `loaderDeps`, one `beforeLoad`, active link state, route hooks, selectors, and `scrollRestoration: true`. + +The weakness is attribution. A regression in any of those areas moves one benchmark, so the future benchmark suite should keep the current mixed loop as a baseline and add isolated scenario apps like `benchmarks/ssr/scenarios/*`. + +## Included Feature Areas + +| Feature area | Scenario plan | Why it gains from CPU coverage | +| ---------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Existing mixed client navigation | `01-baseline-navigation.md` | Preserves CodSpeed continuity and keeps a broad smoke signal while isolated scenarios are added. | +| Route matching and path params | `02-route-matching.md` | Matching large route trees, static/dynamic/splat precedence, params parse/stringify, and not-found fallthrough are hot in every navigation. | +| Location building, links, active state, and match checks | `03-location-building-links.md` | Large menus repeatedly call `buildLocation`, resolve relative paths, compute active state, and run `MatchRoute` checks. | +| Search params, validation, middleware, and structural sharing | `04-search-params.md` | URL search state is a core TanStack Router feature with parse, stringify, validation, deep equality, and middleware costs. | +| `beforeLoad`, route context, and context invalidation | `05-before-load-context.md` | Client navigation serializes `beforeLoad` work and merges context top-down before loaders and components consume it. | +| Loader cache, `loaderDeps`, invalidation, and stale reload modes | `06-loader-cache.md` | Client loader caching is a major runtime responsibility and has cache-hit, cache-miss, stale, background reload, and blocking reload paths. | +| Preloading and preload promotion | `07-preloading.md` | Manual and link-driven preloads perform matching, `beforeLoad`, loader work, dedupe, cache retention, and promotion to navigation. | +| Hook subscribers, selectors, stores, and structural sharing | `08-subscribers-selectors.md` | Framework bindings depend on fine-grained subscriptions to keep render work bounded during frequent location changes. | +| Nested outlets, route lifecycle callbacks, and remount deps | `09-outlets-remounts.md` | Deep route rendering and remount decisions affect client CPU independently from route matching and loaders. | +| Client control-flow paths | `10-control-flow.md` | Redirects, not-found, validation errors, loader errors, and error boundaries alter match resolution and commit paths. | +| Interrupted navigations and abort handling | `11-interrupted-navigations.md` | Superseded navigations stress abort controllers, pending match cleanup, and loader resolution ordering. | +| Scroll restoration and hash navigation | `12-scroll-restoration.md` | Scroll restoration tracks scroll containers, cache keys, reset behavior, and hash/top scrolling during navigation. | +| Route masking, rewrites, basepath, and trailing slash handling | `13-masking-rewrites.md` | Public/internal href transforms and temp-location state add location-building and navigation overhead. | +| Client head management | `14-head-management.md` | Nested `head`, `links`, and `scripts` evaluation plus DOM dedupe happens on client navigation. | +| Deferred loader data and `Await` | `15-deferred-await.md` | Deferred promises exercise non-blocking loader results, suspense/await resolution, and follow-up route rendering. | +| History integration, router events, and navigation blockers | `16-history-events-blockers.md` | Back/forward, event subscribers, and blockers are client-only runtime paths not isolated by the existing mixed loop. | +| Client hydration and resume | `17-hydration-resume.md` | Hydrated matches, dehydrated loader data, skipped first-load work, deferred placeholders, and first post-hydration navigation are client CPU paths not covered by SSR request loops. | + +## Considered But Not Included As Standalone Scenarios + +| Feature | Decision | Reason | +| ----------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| TanStack Start SSR, server functions, server routes, middleware, streaming, and serialization | Excluded | These are server-side responsibilities already covered by `benchmarks/ssr` or memory benchmarks. | +| Memory retention shapes such as mount/unmount, unique-location churn, and loader-data retention | Excluded from CPU plans | They are already covered by `benchmarks/memory/client`; CPU scenarios should avoid duplicating memory-only signals unless the same feature has meaningful CPU work. | +| Type-safety helpers, `linkOptions`, route type utilities, and TypeScript inference | Excluded | They are compile-time features, not client runtime CPU work. | +| File-route generation, virtual route generation, and bundler plugin transforms | Excluded | They are build-time features. Runtime file-route output can still be used by scenario apps. | +| Cold dynamic import and automatic code-splitting cost | Excluded as a standalone CPU scenario | Node module caching and Vite chunk loading make cold import timing noisy under repeated benchmark invocations. Deterministic warm lazy route and component preload paths are included in `07-preloading.md`. | +| External libraries such as TanStack Query, Zod, Valibot, ArkType, and UI libraries | Excluded | Their CPU costs belong mostly to those libraries. Router scenarios may use lightweight in-house validators instead. | +| Devtools integration | Excluded | Devtools are development-only and should not affect production client CPU benchmarks. | +| Real browser painting, layout, CSS, assets, and image loading | Excluded | The client benchmark runs in jsdom and should not pretend to measure browser rendering. | +| `document.startViewTransition` and view-transition animation work | Excluded | The API is browser-dominated and unavailable or synthetic in jsdom; correctness tests are a better fit. | +| `reloadDocument`, external URL navigation, and full page unload | Excluded | They intentionally leave the SPA router runtime and are not useful CPU loops. | +| Native `beforeunload` prompts and `window.confirm` UX | Excluded | Browser dialogs cannot be benchmarked meaningfully in jsdom. Logical blockers are included in `16-history-events-blockers.md`. | +| Protocol/security warnings for dangerous URLs | Excluded | Rare defensive paths should stay in correctness tests. | +| Real IntersectionObserver viewport behavior | Excluded as a browser API | The preloading scenario can use a deterministic polyfill or direct preload calls to cover router preload work without depending on real viewport observation. | +| Accessibility focus management | Excluded | Not a primary TanStack Router runtime responsibility and difficult to measure accurately in jsdom. | +| Actual network fetch latency | Excluded | Scenarios should use deterministic synchronous or controlled async loaders so router CPU dominates. | diff --git a/benchmarks/client-nav/README.md b/benchmarks/client-nav/README.md index 44b58107e3..eb4798bc52 100644 --- a/benchmarks/client-nav/README.md +++ b/benchmarks/client-nav/README.md @@ -11,6 +11,59 @@ Cross-framework client-side navigation benchmarks for: - `react/` - React benchmark + Vitest config - `solid/` - Solid benchmark + Vitest config - `vue/` - Vue benchmark + Vitest config +- `scenarios///` - isolated scenario apps added after the shared harness lands +- `bench-utils.ts` - stable tinybench options and deterministic seeded helpers +- `benchmark.ts` - shared workload contract for scenario bench files +- `lifecycle.ts` - shared mount/action/wait/teardown harness for scenario setup files + +## Scenario Contract + +The top-level `react/`, `solid/`, and `vue/` apps remain the baseline benchmarks +for CodSpeed continuity. New feature coverage should live under +`scenarios///` and use stable project names of the form +`@benchmarks/client-nav--`. + +Each scenario app exports `mountTestApp(container)` and returns +`{ router, unmount }`; `unmount` should be safe to call twice. Scenario setup +files import the built app from `./dist/app.js` with +`await import(/* @vite-ignore */ appModulePath)` and can use +`createClientNavLifecycle` from `#client-nav/lifecycle` for idempotent mount, +navigation waits, cleanup registration, and teardown. + +Scenario `vite.config.ts` files should set their root to the framework directory, +build library mode from `./src/app.tsx`, and write `./dist/app.js`. Scenario +projects should expose `build:client`, `build:client:flame`, `test:flame`, and +`test:types:client` targets; flame targets should set `parallelism: false`. + +Scenario `speed.bench.ts` files must run `await workload.sanity()` before +`describe(...)`, then register both Vitest `beforeAll`/`afterAll` hooks and +tinybench `setup`/`teardown` options so CodSpeed and plain `vitest bench` both +exercise setup and teardown. + +## Scenario Responsibilities + +The current aggregate targets include the baseline apps and every completed +scenario through plan 17. + +| Scenario | Client-side responsibility | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Baseline apps | Broad mixed navigation loop preserving current CodSpeed continuity. | +| `route-matching` | Large route-tree matching, route sorting, params parse/stringify, splat and optional segments. | +| `location-building-links` | `buildLocation`, `Link`, active state, relative targets, hash/search/state preservation, and match checks. | +| `search-params` | Search parse/stringify, validation, middleware, deep equality, custom serialization, and structural sharing. | +| `before-load-context` | Serial `beforeLoad`, context merge, context consumers, and context invalidation. | +| `loader-cache` | Loaders, `loaderDeps`, fresh/stale cache paths, `shouldReload`, stale reload modes, and invalidation. | +| `preloading` | Manual/link preloads, dedupe, component preload hooks, and preload-to-navigation promotion. | +| `subscribers-selectors` | Framework hook subscriptions, selectors, router stores, and structural sharing. | +| `outlets-remounts` | Deep outlets, lifecycle callbacks, and remount deps. | +| `control-flow` | Redirects, not-found, validation errors, loader errors, error boundaries, and unmatched routes. | +| `interrupted-navigations` | Superseded navigations, abort signals, pending match cleanup, and final commit ordering. | +| `scroll-restoration` | Scroll cache bookkeeping, nested scroll areas, hash scrolling, and reset behavior. | +| `masking-rewrites` | Route masks, URL rewrites, basepath, public hrefs, and trailing slash handling. | +| `head-management` | Nested route head evaluation, DOM head updates, and dedupe. | +| `deferred-await` | Deferred loader data, fallback rendering, controlled promise resolution, and follow-up renders. | +| `history-events-blockers` | History push/back/forward, router events, and logical navigation blockers. | +| `hydration-resume` | Client hydration of dehydrated matches, skipped first-load loader work, deferred placeholders, and first post-hydration navigation. | ## Run @@ -28,6 +81,14 @@ CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf:solid --output CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf:vue --outputStyle=stream --skipRemoteCache ``` +Build a framework's baseline app and completed scenario apps: + +```bash +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:build:react --outputStyle=stream --skipRemoteCache +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:build:solid --outputStyle=stream --skipRemoteCache +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:build:vue --outputStyle=stream --skipRemoteCache +``` + Run framework-specific flame benchmarks (10 second loop, profiled with `@platformatic/flame`, forced to `NODE_ENV=production`): ```bash @@ -41,3 +102,15 @@ Typecheck benchmark sources: ```bash CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:types --outputStyle=stream --skipRemoteCache ``` + +Run a completed scenario target directly by project name when debugging a single +scenario: + +```bash +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-route-matching-react:build:client --outputStyle=stream --skipRemoteCache +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-route-matching-react:test:types:client --outputStyle=stream --skipRemoteCache +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-route-matching-react:test:flame --outputStyle=stream --skipRemoteCache +``` + +Replace `route-matching` and `react` in the project name with any completed +scenario and framework pair listed in the responsibility table. diff --git a/benchmarks/client-nav/bench-utils.ts b/benchmarks/client-nav/bench-utils.ts new file mode 100644 index 0000000000..33f4235ffe --- /dev/null +++ b/benchmarks/client-nav/bench-utils.ts @@ -0,0 +1,18 @@ +export const clientNavBenchOptions = { + warmupIterations: 100, + time: 10_000, + throws: true, +} + +export function createDeterministicRandom(seed: number) { + let state = seed >>> 0 + + return () => { + state = (state * 1664525 + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +export function randomSegment(random: () => number) { + return Math.floor(random() * 1_000_000_000).toString(36) +} diff --git a/benchmarks/client-nav/benchmark.ts b/benchmarks/client-nav/benchmark.ts new file mode 100644 index 0000000000..1d0680a3ab --- /dev/null +++ b/benchmarks/client-nav/benchmark.ts @@ -0,0 +1,7 @@ +export interface ClientNavWorkload { + name: string + before: () => Promise | void + run: () => Promise | void + sanity: () => Promise | void + after: () => Promise | void +} diff --git a/benchmarks/client-nav/lifecycle.ts b/benchmarks/client-nav/lifecycle.ts new file mode 100644 index 0000000000..30eb9ef1f1 --- /dev/null +++ b/benchmarks/client-nav/lifecycle.ts @@ -0,0 +1,456 @@ +import type { + AnyRouter, + NavigateOptions, + RouterEvents, +} from '@tanstack/router-core' +import { getRequiredLink, waitForRequiredLink } from './setup-helpers' + +export type Framework = 'react' | 'solid' | 'vue' + +export interface MountedTestApp { + router: TRouter + unmount: () => void +} + +export type MountTestApp = ( + container: HTMLDivElement, +) => MountedTestApp + +export type CleanupFn = () => Promise | void + +export type ActionWaitMode = 'rendered' | 'resolved' | 'idle' | 'none' + +export interface WaitOptions { + label?: string + timeoutMs?: number +} + +export interface ActionWaitOptions extends WaitOptions { + wait?: ActionWaitMode +} + +export interface CreateClientNavLifecycleOptions< + TRouter extends AnyRouter = AnyRouter, +> { + mountTestApp: MountTestApp + timeoutMs?: number + load?: boolean +} + +type EventWaiter = { + resolve: () => void + reject: (error: Error) => void +} + +type ActiveMount = { + container: HTMLDivElement + router: TRouter + unmount: () => void + unsubscribers: Array<() => void> +} + +const DEFAULT_TIMEOUT_MS = 2_000 + +const frameworkNames = { + react: 'React', + solid: 'Solid', + vue: 'Vue', +} satisfies Record + +export function warnClientNavDevMode(framework: Framework) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + `client-nav benchmark is running without NODE_ENV=production; ${frameworkNames[framework]} dev overhead will dominate results.`, + ) + } +} + +export function createBenchContainer() { + const container = document.createElement('div') + document.body.append(container) + + return container +} + +export function nextAnimationFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }) +} + +export async function waitWithTimeout( + value: Promise | T, + { + label = 'client-nav wait', + timeoutMs = DEFAULT_TIMEOUT_MS, + }: WaitOptions = {}, +) { + let timeout: ReturnType | undefined = undefined + + try { + return await Promise.race([ + Promise.resolve(value), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + }), + ]) + } finally { + if (timeout !== undefined) { + clearTimeout(timeout) + } + } +} + +export function createClientNavLifecycle< + TRouter extends AnyRouter = AnyRouter, +>({ + mountTestApp, + timeoutMs = DEFAULT_TIMEOUT_MS, + load = true, +}: CreateClientNavLifecycleOptions) { + let activeMount: ActiveMount | undefined = undefined + const renderWaiters = new Set() + const resolvedWaiters = new Set() + const cleanups: Array = [] + + const resolveWaiters = (waiters: Set) => { + const pending = Array.from(waiters) + waiters.clear() + + for (const waiter of pending) { + waiter.resolve() + } + } + + const rejectWaiters = (error: Error) => { + const waiters = [...renderWaiters, ...resolvedWaiters] + renderWaiters.clear() + resolvedWaiters.clear() + + for (const waiter of waiters) { + waiter.reject(error) + } + } + + const createEventWaiter = (waiters: Set) => { + let waiter: EventWaiter | undefined = undefined + const promise = new Promise((resolve, reject) => { + waiter = { resolve, reject } + waiters.add(waiter) + }) + + return { + promise, + cancel() { + if (waiter) { + waiters.delete(waiter) + } + }, + } + } + + const getActiveMount = () => { + if (!activeMount) { + throw new Error('Client navigation benchmark app is not mounted') + } + + return activeMount + } + + const getRouter = () => getActiveMount().router + const getContainer = () => getActiveMount().container + + const waitForEvent = async ( + eventType: Extract, + action?: () => Promise | unknown, + options: WaitOptions = {}, + ) => { + const waiters = eventType === 'onRendered' ? renderWaiters : resolvedWaiters + const waiter = createEventWaiter(waiters) + const label = options.label ?? eventType + + try { + const actionResult = action?.() + await waitWithTimeout( + Promise.all([Promise.resolve(actionResult), waiter.promise]), + { label, timeoutMs: options.timeoutMs ?? timeoutMs }, + ) + } finally { + waiter.cancel() + } + } + + const waitForRouterIdle = (options: WaitOptions = {}) => { + const router = getRouter() + + return waitForCounter( + () => (router.state.status === 'idle' && !router.state.isLoading ? 1 : 0), + 1, + { + label: options.label ?? 'router idle', + timeoutMs: options.timeoutMs ?? timeoutMs, + }, + ) + } + + const waitForAction = async ( + action: () => Promise | unknown, + { wait = 'rendered', ...options }: ActionWaitOptions = {}, + ) => { + if (wait === 'rendered') { + await waitForEvent('onRendered', action, options) + return + } + + if (wait === 'resolved') { + await waitForEvent('onResolved', action, options) + return + } + + const actionResult = action() + + if (wait === 'idle') { + await Promise.resolve(actionResult) + await waitForRouterIdle(options) + return + } + + await waitWithTimeout(Promise.resolve(actionResult), { + label: options.label ?? 'client-nav action', + timeoutMs: options.timeoutMs ?? timeoutMs, + }) + } + + const runCleanups = async (errors: Array) => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop()! + + try { + await cleanup() + } catch (error) { + errors.push(error) + } + } + } + + async function before() { + await after() + + const container = createBenchContainer() + + try { + const { router, unmount } = mountTestApp(container) + const unsubscribers = [ + router.subscribe('onRendered', () => { + resolveWaiters(renderWaiters) + }), + router.subscribe('onResolved', () => { + resolveWaiters(resolvedWaiters) + }), + ] + + activeMount = { + container, + router, + unmount, + unsubscribers, + } + + if (load) { + await waitWithTimeout(router.load(), { + label: 'initial router.load()', + timeoutMs, + }) + } + } catch (error) { + container.remove() + await after() + throw error + } + } + + async function after() { + const mount = activeMount + activeMount = undefined + + const errors: Array = [] + + if (mount) { + try { + mount.unmount() + } catch (error) { + errors.push(error) + } + + for (const unsubscribe of mount.unsubscribers.splice(0).reverse()) { + try { + unsubscribe() + } catch (error) { + errors.push(error) + } + } + + try { + mount.container.remove() + } catch (error) { + errors.push(error) + } + + try { + if ( + typeof self !== 'undefined' && + self.__TSR_ROUTER__ === mount.router + ) { + self.__TSR_ROUTER__ = undefined + } + } catch (error) { + errors.push(error) + } + + try { + mount.router.history.destroy() + } catch (error) { + errors.push(error) + } + } + + rejectWaiters(new Error('Client navigation benchmark app was unmounted')) + await runCleanups(errors) + + if (errors.length === 1) { + throw errors[0] + } + + if (errors.length > 1) { + throw new AggregateError( + errors, + 'Client navigation benchmark teardown failed', + ) + } + } + + function addCleanup(cleanup: CleanupFn) { + cleanups.push(cleanup) + let registered = true + + return () => { + if (!registered) { + return + } + + registered = false + const index = cleanups.lastIndexOf(cleanup) + + if (index !== -1) { + cleanups.splice(index, 1) + } + } + } + + function navigate(options: NavigateOptions, waitOptions?: ActionWaitOptions) { + return waitForAction(() => getRouter().navigate(options), { + label: 'router.navigate()', + ...waitOptions, + }) + } + + function dispatchClick(testId: string) { + getRequiredLink(getContainer(), testId).dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + button: 0, + }), + ) + } + + function click(testId: string, options: ActionWaitOptions = {}) { + return waitForAction(() => dispatchClick(testId), { + label: `click ${testId}`, + ...options, + }) + } + + function waitForLink(testId: string) { + return waitForRequiredLink(getContainer(), testId) + } + + function waitForCounterWithDefaultTimeout( + readCounter: () => number, + target: number | ((value: number) => boolean), + options?: WaitOptions, + ) { + return waitForCounter(readCounter, target, { + timeoutMs, + ...options, + }) + } + + return { + before, + after, + addCleanup, + click, + dispatchClick, + getContainer, + getRouter, + navigate, + waitForCounter: waitForCounterWithDefaultTimeout, + waitForLink, + waitForPromise(value: Promise | T, options?: WaitOptions) { + return waitWithTimeout(value, { + timeoutMs, + ...options, + }) + }, + waitForRender( + action?: () => Promise | unknown, + options?: WaitOptions, + ) { + return waitForEvent('onRendered', action, options) + }, + waitForResolved( + action?: () => Promise | unknown, + options?: WaitOptions, + ) { + return waitForEvent('onResolved', action, options) + }, + waitForRouterIdle, + } +} + +export async function waitForCounter( + readCounter: () => number, + target: number | ((value: number) => boolean), + { label = 'counter wait', timeoutMs = DEFAULT_TIMEOUT_MS }: WaitOptions = {}, +) { + let cancelled = false + const isReady = (value: number) => + typeof target === 'number' ? value >= target : target(value) + + try { + await waitWithTimeout( + new Promise((resolve) => { + const check = () => { + if (cancelled) { + return + } + + if (isReady(readCounter())) { + resolve() + return + } + + requestAnimationFrame(() => check()) + } + + check() + }), + { label, timeoutMs }, + ) + } finally { + cancelled = true + } +} diff --git a/benchmarks/client-nav/package.json b/benchmarks/client-nav/package.json index cb86e70654..4f59a1b6b5 100644 --- a/benchmarks/client-nav/package.json +++ b/benchmarks/client-nav/package.json @@ -10,14 +10,21 @@ "test:flame:solid": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" ./solid/speed.flame.ts", "test:flame:vue": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" ./vue/speed.flame.ts", "test:perf": "NODE_ENV=production vitest bench --config ./vitest.config.ts", - "test:perf:react": "NODE_ENV=production vitest bench --config ./react/vite.config.ts ./react/speed.bench.ts", - "test:perf:solid": "NODE_ENV=production vitest bench --config ./solid/vite.config.ts ./solid/speed.bench.ts", - "test:perf:vue": "NODE_ENV=production vitest bench --config ./vue/vite.config.ts ./vue/speed.bench.ts", - "test:types": "pnpm run test:types:react && pnpm run test:types:solid && pnpm run test:types:vue", + "test:perf:react": "NODE_ENV=production vitest bench --config ./vitest.react.config.ts", + "test:perf:solid": "NODE_ENV=production vitest bench --config ./vitest.solid.config.ts", + "test:perf:vue": "NODE_ENV=production vitest bench --config ./vitest.vue.config.ts", + "test:types": "pnpm run test:types:shared && pnpm run test:types:react && pnpm run test:types:solid && pnpm run test:types:vue", + "test:types:shared": "tsc -p ./tsconfig.json --noEmit", "test:types:react": "tsc -p ./react/tsconfig.json --noEmit", "test:types:solid": "tsc -p ./solid/tsconfig.json --noEmit", "test:types:vue": "tsc -p ./vue/tsconfig.json --noEmit" }, + "imports": { + "#client-nav/benchmark": "./benchmark.ts", + "#client-nav/bench-utils": "./bench-utils.ts", + "#client-nav/lifecycle": "./lifecycle.ts", + "#client-nav/setup-helpers": "./setup-helpers.ts" + }, "dependencies": { "@tanstack/react-router": "workspace:^", "@tanstack/router-core": "workspace:^", @@ -51,6 +58,27 @@ "@tanstack/react-router" ], "target": "build" + }, + { + "projects": [ + "@benchmarks/client-nav-route-matching-react", + "@benchmarks/client-nav-location-building-links-react", + "@benchmarks/client-nav-search-params-react", + "@benchmarks/client-nav-before-load-context-react", + "@benchmarks/client-nav-loader-cache-react", + "@benchmarks/client-nav-preloading-react", + "@benchmarks/client-nav-subscribers-selectors-react", + "@benchmarks/client-nav-outlets-remounts-react", + "@benchmarks/client-nav-control-flow-react", + "@benchmarks/client-nav-interrupted-navigations-react", + "@benchmarks/client-nav-scroll-restoration-react", + "@benchmarks/client-nav-masking-rewrites-react", + "@benchmarks/client-nav-head-management-react", + "@benchmarks/client-nav-deferred-await-react", + "@benchmarks/client-nav-history-events-blockers-react", + "@benchmarks/client-nav-hydration-resume-react" + ], + "target": "build:client" } ] }, @@ -62,6 +90,27 @@ "@tanstack/solid-router" ], "target": "build" + }, + { + "projects": [ + "@benchmarks/client-nav-route-matching-solid", + "@benchmarks/client-nav-location-building-links-solid", + "@benchmarks/client-nav-search-params-solid", + "@benchmarks/client-nav-before-load-context-solid", + "@benchmarks/client-nav-loader-cache-solid", + "@benchmarks/client-nav-preloading-solid", + "@benchmarks/client-nav-subscribers-selectors-solid", + "@benchmarks/client-nav-outlets-remounts-solid", + "@benchmarks/client-nav-control-flow-solid", + "@benchmarks/client-nav-interrupted-navigations-solid", + "@benchmarks/client-nav-scroll-restoration-solid", + "@benchmarks/client-nav-masking-rewrites-solid", + "@benchmarks/client-nav-head-management-solid", + "@benchmarks/client-nav-deferred-await-solid", + "@benchmarks/client-nav-history-events-blockers-solid", + "@benchmarks/client-nav-hydration-resume-solid" + ], + "target": "build:client" } ] }, @@ -73,10 +122,32 @@ "@tanstack/vue-router" ], "target": "build" + }, + { + "projects": [ + "@benchmarks/client-nav-route-matching-vue", + "@benchmarks/client-nav-location-building-links-vue", + "@benchmarks/client-nav-search-params-vue", + "@benchmarks/client-nav-before-load-context-vue", + "@benchmarks/client-nav-loader-cache-vue", + "@benchmarks/client-nav-preloading-vue", + "@benchmarks/client-nav-subscribers-selectors-vue", + "@benchmarks/client-nav-outlets-remounts-vue", + "@benchmarks/client-nav-control-flow-vue", + "@benchmarks/client-nav-interrupted-navigations-vue", + "@benchmarks/client-nav-scroll-restoration-vue", + "@benchmarks/client-nav-masking-rewrites-vue", + "@benchmarks/client-nav-head-management-vue", + "@benchmarks/client-nav-deferred-await-vue", + "@benchmarks/client-nav-history-events-blockers-vue", + "@benchmarks/client-nav-hydration-resume-vue" + ], + "target": "build:client" } ] }, "test:perf": { + "parallelism": false, "cache": false, "dependsOn": [ "build:react", @@ -85,36 +156,105 @@ ] }, "test:flame:react": { + "parallelism": false, "cache": false, "dependsOn": [ - "build:react" + "build:react", + { + "projects": [ + "@benchmarks/client-nav-route-matching-react", + "@benchmarks/client-nav-location-building-links-react", + "@benchmarks/client-nav-search-params-react", + "@benchmarks/client-nav-before-load-context-react", + "@benchmarks/client-nav-loader-cache-react", + "@benchmarks/client-nav-preloading-react", + "@benchmarks/client-nav-subscribers-selectors-react", + "@benchmarks/client-nav-outlets-remounts-react", + "@benchmarks/client-nav-control-flow-react", + "@benchmarks/client-nav-interrupted-navigations-react", + "@benchmarks/client-nav-scroll-restoration-react", + "@benchmarks/client-nav-masking-rewrites-react", + "@benchmarks/client-nav-head-management-react", + "@benchmarks/client-nav-deferred-await-react", + "@benchmarks/client-nav-history-events-blockers-react", + "@benchmarks/client-nav-hydration-resume-react" + ], + "target": "test:flame" + } ] }, "test:flame:solid": { + "parallelism": false, "cache": false, "dependsOn": [ - "build:solid" + "build:solid", + { + "projects": [ + "@benchmarks/client-nav-route-matching-solid", + "@benchmarks/client-nav-location-building-links-solid", + "@benchmarks/client-nav-search-params-solid", + "@benchmarks/client-nav-before-load-context-solid", + "@benchmarks/client-nav-loader-cache-solid", + "@benchmarks/client-nav-preloading-solid", + "@benchmarks/client-nav-subscribers-selectors-solid", + "@benchmarks/client-nav-outlets-remounts-solid", + "@benchmarks/client-nav-control-flow-solid", + "@benchmarks/client-nav-interrupted-navigations-solid", + "@benchmarks/client-nav-scroll-restoration-solid", + "@benchmarks/client-nav-masking-rewrites-solid", + "@benchmarks/client-nav-head-management-solid", + "@benchmarks/client-nav-deferred-await-solid", + "@benchmarks/client-nav-history-events-blockers-solid", + "@benchmarks/client-nav-hydration-resume-solid" + ], + "target": "test:flame" + } ] }, "test:flame:vue": { + "parallelism": false, "cache": false, "dependsOn": [ - "build:vue" + "build:vue", + { + "projects": [ + "@benchmarks/client-nav-route-matching-vue", + "@benchmarks/client-nav-location-building-links-vue", + "@benchmarks/client-nav-search-params-vue", + "@benchmarks/client-nav-before-load-context-vue", + "@benchmarks/client-nav-loader-cache-vue", + "@benchmarks/client-nav-preloading-vue", + "@benchmarks/client-nav-subscribers-selectors-vue", + "@benchmarks/client-nav-outlets-remounts-vue", + "@benchmarks/client-nav-control-flow-vue", + "@benchmarks/client-nav-interrupted-navigations-vue", + "@benchmarks/client-nav-scroll-restoration-vue", + "@benchmarks/client-nav-masking-rewrites-vue", + "@benchmarks/client-nav-head-management-vue", + "@benchmarks/client-nav-deferred-await-vue", + "@benchmarks/client-nav-history-events-blockers-vue", + "@benchmarks/client-nav-hydration-resume-vue" + ], + "target": "test:flame" + } ] }, "test:perf:react": { + "parallelism": false, "cache": false, "dependsOn": [ "build:react" ] }, "test:perf:solid": { + "parallelism": false, "cache": false, "dependsOn": [ "build:solid" ] }, "test:perf:vue": { + "parallelism": false, "cache": false, "dependsOn": [ "build:vue" @@ -123,7 +263,60 @@ "test:types": { "cache": false, "dependsOn": [ - "^build" + "^build", + { + "projects": [ + "@benchmarks/client-nav-route-matching-react", + "@benchmarks/client-nav-location-building-links-react", + "@benchmarks/client-nav-search-params-react", + "@benchmarks/client-nav-before-load-context-react", + "@benchmarks/client-nav-loader-cache-react", + "@benchmarks/client-nav-preloading-react", + "@benchmarks/client-nav-subscribers-selectors-react", + "@benchmarks/client-nav-outlets-remounts-react", + "@benchmarks/client-nav-control-flow-react", + "@benchmarks/client-nav-interrupted-navigations-react", + "@benchmarks/client-nav-scroll-restoration-react", + "@benchmarks/client-nav-masking-rewrites-react", + "@benchmarks/client-nav-head-management-react", + "@benchmarks/client-nav-deferred-await-react", + "@benchmarks/client-nav-history-events-blockers-react", + "@benchmarks/client-nav-hydration-resume-react", + "@benchmarks/client-nav-route-matching-solid", + "@benchmarks/client-nav-location-building-links-solid", + "@benchmarks/client-nav-search-params-solid", + "@benchmarks/client-nav-before-load-context-solid", + "@benchmarks/client-nav-loader-cache-solid", + "@benchmarks/client-nav-preloading-solid", + "@benchmarks/client-nav-subscribers-selectors-solid", + "@benchmarks/client-nav-outlets-remounts-solid", + "@benchmarks/client-nav-control-flow-solid", + "@benchmarks/client-nav-interrupted-navigations-solid", + "@benchmarks/client-nav-scroll-restoration-solid", + "@benchmarks/client-nav-masking-rewrites-solid", + "@benchmarks/client-nav-head-management-solid", + "@benchmarks/client-nav-deferred-await-solid", + "@benchmarks/client-nav-history-events-blockers-solid", + "@benchmarks/client-nav-hydration-resume-solid", + "@benchmarks/client-nav-route-matching-vue", + "@benchmarks/client-nav-location-building-links-vue", + "@benchmarks/client-nav-search-params-vue", + "@benchmarks/client-nav-before-load-context-vue", + "@benchmarks/client-nav-loader-cache-vue", + "@benchmarks/client-nav-preloading-vue", + "@benchmarks/client-nav-subscribers-selectors-vue", + "@benchmarks/client-nav-outlets-remounts-vue", + "@benchmarks/client-nav-control-flow-vue", + "@benchmarks/client-nav-interrupted-navigations-vue", + "@benchmarks/client-nav-scroll-restoration-vue", + "@benchmarks/client-nav-masking-rewrites-vue", + "@benchmarks/client-nav-head-management-vue", + "@benchmarks/client-nav-deferred-await-vue", + "@benchmarks/client-nav-history-events-blockers-vue", + "@benchmarks/client-nav-hydration-resume-vue" + ], + "target": "test:types:client" + } ] } } diff --git a/benchmarks/client-nav/react/app.tsx b/benchmarks/client-nav/react/app.tsx deleted file mode 100644 index cad766e26c..0000000000 --- a/benchmarks/client-nav/react/app.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import { - Link, - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - useParams, - useSearch, -} from '@tanstack/react-router' -import { createRoot } from 'react-dom/client' - -function runPerfSelectorComputation(seed: number) { - let value = Math.trunc(seed) | 0 - - for (let index = 0; index < 40; index++) { - value = (value * 1664525 + 1013904223 + index) >>> 0 - } - - return value -} - -function normalizePage(value: unknown) { - const page = Number(value) - return Number.isFinite(page) && page > 0 ? Math.trunc(page) : 1 -} - -function normalizeFilter(value: unknown) { - return typeof value === 'string' && value.length > 0 ? value : 'all' -} - -const noop = () => {} -const rootSelectors = Array.from({ length: 10 }, (_, index) => index) -const routeSelectors = Array.from({ length: 6 }, (_, index) => index) -const linkGroups = Array.from({ length: 4 }, (_, index) => index) - -function RootParamsSubscriber() { - const params = useParams({ - strict: false, - select: (params) => runPerfSelectorComputation(Number(params.id ?? 0)), - }) - - void runPerfSelectorComputation(params) - return null -} - -function RootSearchSubscriber() { - const search = useSearch({ - strict: false, - select: (search) => runPerfSelectorComputation(Number(search.page ?? 0)), - }) - - void runPerfSelectorComputation(search) - return null -} - -function LinkPanel() { - return ( - <> - {linkGroups.map((groupIndex) => { - const itemsId = groupIndex === 0 ? 1 : groupIndex + 2 - const ctxId = groupIndex + 1 - - return ( -
- - {`Items ${itemsId}`} - - - {`Items 2 alt ${groupIndex}`} - - - {`Search ${groupIndex}`} - - - {`Context ${ctxId}`} - - ({ - page: prev.page + groupIndex + 1, - filter: prev.filter, - junk: `updater-${groupIndex}`, - })} - activeOptions={{ includeSearch: true }} - > - {({ isActive }) => - isActive - ? `Search updater active ${groupIndex}` - : `Search updater inactive ${groupIndex}` - } - -
- ) - })} - - ) -} - -function Root() { - return ( - <> - {rootSelectors.map((selector) => ( - - ))} - {rootSelectors.map((selector) => ( - - ))} - - - - ) -} - -const rootRoute = createRootRoute({ - component: Root, -}) - -const itemsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/items/$id', - params: { - parse: (params) => ({ - ...params, - id: normalizePage(params.id), - }), - stringify: (params) => ({ - ...params, - id: `${params.id}`, - }), - }, - onEnter: noop, - onStay: noop, - onLeave: noop, - component: ItemsPage, -}) - -const itemDetailsRoute = createRoute({ - getParentRoute: () => itemsRoute, - path: 'details', - component: ItemDetailsPage, -}) - -const searchRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/search', - validateSearch: (search: Record) => ({ - page: normalizePage(search.page), - filter: normalizeFilter(search.filter), - }), - search: { - middlewares: [ - ({ search, next }) => { - const result = next(search) - return { - page: result.page, - filter: result.filter, - } - }, - ], - }, - loaderDeps: ({ search }) => ({ - page: search.page, - filter: search.filter, - }), - loader: ({ deps }) => ({ - seed: deps.page * 31 + deps.filter.length, - checksum: deps.page * 17 + deps.filter.length, - }), - staleTime: 60_000, - gcTime: 60_000, - component: SearchPage, -}) - -const contextRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/ctx/$id', - beforeLoad: ({ params }) => ({ - sectionSeed: Number(params.id) * 13 + 1, - }), - component: ContextPage, -}) - -function ItemParamsSubscriber() { - const params = itemsRoute.useParams({ - select: (params) => runPerfSelectorComputation(params.id), - }) - - void runPerfSelectorComputation(params) - return null -} - -function SearchStateSubscriber() { - const search = searchRoute.useSearch({ - select: (search) => - runPerfSelectorComputation(search.page + search.filter.length), - }) - - void runPerfSelectorComputation(search) - return null -} - -function SearchLoaderDepsSubscriber() { - const loaderDeps = searchRoute.useLoaderDeps({ - select: (loaderDeps) => - runPerfSelectorComputation(loaderDeps.page + loaderDeps.filter.length), - }) - - void runPerfSelectorComputation(loaderDeps) - return null -} - -function SearchLoaderDataSubscriber() { - const loaderData = searchRoute.useLoaderData({ - select: (loaderData) => - runPerfSelectorComputation(loaderData.seed + loaderData.checksum), - }) - - void runPerfSelectorComputation(loaderData) - return null -} - -function ContextParamsSubscriber() { - const params = contextRoute.useParams({ - select: (params) => runPerfSelectorComputation(Number(params.id)), - }) - - void runPerfSelectorComputation(params) - return null -} - -function ContextRouteSubscriber() { - const context = contextRoute.useRouteContext({ - select: (context) => runPerfSelectorComputation(context.sectionSeed), - }) - - void runPerfSelectorComputation(context) - return null -} - -function ItemsPage() { - return ( - <> - {routeSelectors.map((selector) => ( - - ))} - - Details - - - Preserve search on item - - - - ) -} - -function ItemDetailsPage() { - return ( - <> - {routeSelectors.map((selector) => ( - - ))} - - Back to item - - - ) -} - -function SearchPage() { - return ( - <> - {routeSelectors.map((selector) => ( - - ))} - {routeSelectors.map((selector) => ( - - ))} - {routeSelectors.map((selector) => ( - - ))} - ({ - page: prev.page + 1, - filter: prev.filter, - junk: 'local-updater', - })} - activeOptions={{ includeSearch: true }} - activeProps={{ className: 'active-link' }} - inactiveProps={{ className: 'inactive-link' }} - > - Next page - - - ) -} - -function ContextPage() { - return ( - <> - {routeSelectors.map((selector) => ( - - ))} - {routeSelectors.map((selector) => ( - - ))} - - ) -} - -export function mountTestApp(container: Element) { - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/items/0'], - }), - scrollRestoration: true, - routeTree: rootRoute.addChildren([ - itemsRoute.addChildren([itemDetailsRoute]), - searchRoute, - contextRoute, - ]), - }) - - const reactRoot = createRoot(container) - reactRoot.render() - - return { - router, - unmount() { - reactRoot.unmount() - }, - } -} diff --git a/benchmarks/client-nav/react/setup.ts b/benchmarks/client-nav/react/setup.ts index 11541383ec..56035ca992 100644 --- a/benchmarks/client-nav/react/setup.ts +++ b/benchmarks/client-nav/react/setup.ts @@ -1,6 +1,8 @@ -import type { NavigateOptions } from '@tanstack/router-core' -import type * as App from './app' -import { getRequiredLink, waitForRequiredLink } from '../setup-helpers' +import type * as App from './src/app' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' const appModulePath = './dist/app.js' const { mountTestApp } = (await import( @@ -8,105 +10,76 @@ const { mountTestApp } = (await import( )) as typeof App export function setup() { - if (process.env.NODE_ENV !== 'production') { - console.warn( - 'client-nav benchmark is running without NODE_ENV=production; React dev overhead will dominate results.', - ) - } + warnClientNavDevMode('react') - let container: HTMLDivElement | undefined = undefined - let unmount: (() => void) | undefined = undefined - let unsub = () => {} + const lifecycle = createClientNavLifecycle({ mountTestApp }) let stepIndex = 0 - let next: () => Promise = () => Promise.reject('Test not initialized') + + const steps = [ + () => lifecycle.click('go-items-1'), + () => lifecycle.click('items-details'), + () => + lifecycle.navigate({ + to: '/items/$id/details', + params: { id: 2 }, + replace: true, + }), + () => lifecycle.click('items-parent'), + () => lifecycle.click('go-search'), + () => lifecycle.click('search-next-page'), + () => + lifecycle.navigate({ + to: '/search', + search: { page: 1, filter: 'all' }, + replace: true, + }), + () => lifecycle.click('go-ctx'), + () => + lifecycle.navigate({ + to: '/ctx/$id', + params: { id: 2 }, + replace: true, + }), + () => lifecycle.click('go-items-2'), + ] as const + + async function prepareLinks() { + for (const testId of ['go-items-1', 'go-items-2', 'go-search', 'go-ctx']) { + await lifecycle.waitForLink(testId) + } + } async function before() { stepIndex = 0 - container = document.createElement('div') - document.body.append(container) - - const { router, unmount: dispose } = mountTestApp(container) - unmount = dispose - - let resolveRendered: () => void = () => {} - unsub = router.subscribe('onRendered', () => { - resolveRendered() - }) - - const navigate = (opts: NavigateOptions) => - new Promise((resolveNext) => { - resolveRendered = resolveNext - router.navigate(opts) - }) + await lifecycle.before() + await prepareLinks() + } - const click = (testId: string, cache?: Map) => - new Promise((resolveNext) => { - resolveRendered = resolveNext + async function sanity() { + await lifecycle.before() - getRequiredLink(container!, testId, cache).dispatchEvent( - new MouseEvent('click', { - bubbles: true, - cancelable: true, - button: 0, - }), - ) + try { + await lifecycle.navigate({ + to: '/search', + search: { page: 1, filter: 'all' }, + replace: true, }) - - await router.load() - - const cachedLinks = new Map() - for (const testId of ['go-items-1', 'go-items-2', 'go-search', 'go-ctx']) { - await waitForRequiredLink(container, testId, cachedLinks) + await lifecycle.waitForLink('search-next-page') + } finally { + await lifecycle.after() } - - const steps = [ - () => click('go-items-1', cachedLinks), - () => click('items-details'), - () => - navigate({ - to: '/items/$id/details', - params: { id: 2 }, - replace: true, - }), - () => click('items-parent'), - () => click('go-search', cachedLinks), - () => click('search-next-page'), - () => - navigate({ - to: '/search', - search: { page: 1, filter: 'all' }, - replace: true, - }), - () => click('go-ctx', cachedLinks), - () => - navigate({ - to: '/ctx/$id', - params: { id: 2 }, - replace: true, - }), - () => click('go-items-2', cachedLinks), - ] as const - - next = () => { - const step = steps[stepIndex % steps.length]! - stepIndex += 1 - return step() - } - } - - function after() { - unmount?.() - container?.remove() - unsub() } function tick() { - return next() + const step = steps[stepIndex % steps.length]! + stepIndex += 1 + return step() } return { before, + sanity, tick, - after, + after: lifecycle.after, } } diff --git a/benchmarks/client-nav/react/speed.bench.ts b/benchmarks/client-nav/react/speed.bench.ts index fcc15b5fbc..ec15bc8ea6 100644 --- a/benchmarks/client-nav/react/speed.bench.ts +++ b/benchmarks/client-nav/react/speed.bench.ts @@ -1,9 +1,11 @@ import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' import { setup } from './setup' -describe('client-nav', () => { - const test = setup() +const test = setup() +await test.sanity() +describe('client-nav', () => { /** * Running `vitest bench` ignores "suite hooks" like `beforeAll` and `afterAll`, * so we use tinybench's `setup` and `teardown` options to run our setup and teardown logic. @@ -25,8 +27,7 @@ describe('client-nav', () => { } }, { - warmupIterations: 100, - time: 10_000, + ...clientNavBenchOptions, setup: test.before, teardown: test.after, }, diff --git a/benchmarks/client-nav/react/speed.flame.ts b/benchmarks/client-nav/react/speed.flame.ts index 285ccc63d7..dc36e0ef34 100644 --- a/benchmarks/client-nav/react/speed.flame.ts +++ b/benchmarks/client-nav/react/speed.flame.ts @@ -4,6 +4,7 @@ import { setup } from './setup.ts' const DURATION_MS = 10_000 const test = setup() +await test.sanity() try { await test.before() @@ -13,6 +14,6 @@ try { await test.tick() } } finally { - test.after() + await test.after() window.close() } diff --git a/benchmarks/client-nav/react/src/app.tsx b/benchmarks/client-nav/react/src/app.tsx new file mode 100644 index 0000000000..aba121c056 --- /dev/null +++ b/benchmarks/client-nav/react/src/app.tsx @@ -0,0 +1,17 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + + reactRoot.render() + + return { + router, + unmount() { + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/react/src/routeTree.gen.ts b/benchmarks/client-nav/react/src/routeTree.gen.ts new file mode 100644 index 0000000000..013e9a2496 --- /dev/null +++ b/benchmarks/client-nav/react/src/routeTree.gen.ts @@ -0,0 +1,123 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SearchRouteImport } from './routes/search' +import { Route as ItemsIdRouteImport } from './routes/items.$id' +import { Route as CtxIdRouteImport } from './routes/ctx.$id' +import { Route as ItemsIdDetailsRouteImport } from './routes/items.$id.details' + +const SearchRoute = SearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => rootRouteImport, +} as any) +const ItemsIdRoute = ItemsIdRouteImport.update({ + id: '/items/$id', + path: '/items/$id', + getParentRoute: () => rootRouteImport, +} as any) +const CtxIdRoute = CtxIdRouteImport.update({ + id: '/ctx/$id', + path: '/ctx/$id', + getParentRoute: () => rootRouteImport, +} as any) +const ItemsIdDetailsRoute = ItemsIdDetailsRouteImport.update({ + id: '/details', + path: '/details', + getParentRoute: () => ItemsIdRoute, +} as any) + +export interface FileRoutesByFullPath { + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRoutesByTo { + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + fileRoutesByTo: FileRoutesByTo + to: '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + id: '__root__' | '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + ItemsIdRoute: typeof ItemsIdRouteWithChildren + SearchRoute: typeof SearchRoute + CtxIdRoute: typeof CtxIdRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/search': { + id: '/search' + path: '/search' + fullPath: '/search' + preLoaderRoute: typeof SearchRouteImport + parentRoute: typeof rootRouteImport + } + '/items/$id': { + id: '/items/$id' + path: '/items/$id' + fullPath: '/items/$id' + preLoaderRoute: typeof ItemsIdRouteImport + parentRoute: typeof rootRouteImport + } + '/ctx/$id': { + id: '/ctx/$id' + path: '/ctx/$id' + fullPath: '/ctx/$id' + preLoaderRoute: typeof CtxIdRouteImport + parentRoute: typeof rootRouteImport + } + '/items/$id/details': { + id: '/items/$id/details' + path: '/details' + fullPath: '/items/$id/details' + preLoaderRoute: typeof ItemsIdDetailsRouteImport + parentRoute: typeof ItemsIdRoute + } + } +} + +interface ItemsIdRouteChildren { + ItemsIdDetailsRoute: typeof ItemsIdDetailsRoute +} + +const ItemsIdRouteChildren: ItemsIdRouteChildren = { + ItemsIdDetailsRoute: ItemsIdDetailsRoute, +} + +const ItemsIdRouteWithChildren = ItemsIdRoute._addFileChildren( + ItemsIdRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + ItemsIdRoute: ItemsIdRouteWithChildren, + SearchRoute: SearchRoute, + CtxIdRoute: CtxIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/react/src/router.tsx b/benchmarks/client-nav/react/src/router.tsx new file mode 100644 index 0000000000..0fe71b8096 --- /dev/null +++ b/benchmarks/client-nav/react/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: ['/items/0'], + }), + scrollRestoration: true, + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/react/src/routes/__root.tsx b/benchmarks/client-nav/react/src/routes/__root.tsx new file mode 100644 index 0000000000..6c6d30a1f1 --- /dev/null +++ b/benchmarks/client-nav/react/src/routes/__root.tsx @@ -0,0 +1,127 @@ +import { + Link, + Outlet, + createRootRoute, + useParams, + useSearch, +} from '@tanstack/react-router' +import { + linkGroups, + rootSelectors, + runPerfSelectorComputation, +} from '../shared' +import { Route as SearchRoute } from './search' + +export const Route = createRootRoute({ + component: Root, +}) + +function RootParamsSubscriber() { + const params = useParams({ + strict: false, + select: (params) => runPerfSelectorComputation(Number(params.id ?? 0)), + }) + + void runPerfSelectorComputation(params) + return null +} + +function RootSearchSubscriber() { + const search = useSearch({ + strict: false, + select: (search) => runPerfSelectorComputation(Number(search.page ?? 0)), + }) + + void runPerfSelectorComputation(search) + return null +} + +function LinkPanel() { + return ( + <> + {linkGroups.map((groupIndex) => { + const itemsId = groupIndex === 0 ? 1 : groupIndex + 2 + const ctxId = groupIndex + 1 + + return ( +
+ + {`Items ${itemsId}`} + + + {`Items 2 alt ${groupIndex}`} + + + {`Search ${groupIndex}`} + + + {`Context ${ctxId}`} + + ({ + page: prev.page + groupIndex + 1, + filter: prev.filter, + junk: `updater-${groupIndex}`, + })} + activeOptions={{ includeSearch: true }} + > + {({ isActive }) => + isActive + ? `Search updater active ${groupIndex}` + : `Search updater inactive ${groupIndex}` + } + +
+ ) + })} + + ) +} + +function Root() { + return ( + <> + {rootSelectors.map((selector) => ( + + ))} + {rootSelectors.map((selector) => ( + + ))} + + + + ) +} diff --git a/benchmarks/client-nav/react/src/routes/ctx.$id.tsx b/benchmarks/client-nav/react/src/routes/ctx.$id.tsx new file mode 100644 index 0000000000..97446dbcba --- /dev/null +++ b/benchmarks/client-nav/react/src/routes/ctx.$id.tsx @@ -0,0 +1,40 @@ +import { createFileRoute } from '@tanstack/react-router' +import { routeSelectors, runPerfSelectorComputation } from '../shared' + +export const Route = createFileRoute('/ctx/$id')({ + beforeLoad: ({ params }) => ({ + sectionSeed: Number(params.id) * 13 + 1, + }), + component: ContextPage, +}) + +function ContextParamsSubscriber() { + const params = Route.useParams({ + select: (params) => runPerfSelectorComputation(Number(params.id)), + }) + + void runPerfSelectorComputation(params) + return null +} + +function ContextRouteSubscriber() { + const context = Route.useRouteContext({ + select: (context) => runPerfSelectorComputation(context.sectionSeed), + }) + + void runPerfSelectorComputation(context) + return null +} + +function ContextPage() { + return ( + <> + {routeSelectors.map((selector) => ( + + ))} + {routeSelectors.map((selector) => ( + + ))} + + ) +} diff --git a/benchmarks/client-nav/react/src/routes/items.$id.details.tsx b/benchmarks/client-nav/react/src/routes/items.$id.details.tsx new file mode 100644 index 0000000000..8e86c9e5f0 --- /dev/null +++ b/benchmarks/client-nav/react/src/routes/items.$id.details.tsx @@ -0,0 +1,26 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { routeSelectors } from '../shared' +import { ItemParamsSubscriber } from './items.$id' + +export const Route = createFileRoute('/items/$id/details')({ + component: ItemDetailsPage, +}) + +function ItemDetailsPage() { + return ( + <> + {routeSelectors.map((selector) => ( + + ))} + + Back to item + + + ) +} diff --git a/benchmarks/client-nav/react/src/routes/items.$id.tsx b/benchmarks/client-nav/react/src/routes/items.$id.tsx new file mode 100644 index 0000000000..046754b421 --- /dev/null +++ b/benchmarks/client-nav/react/src/routes/items.$id.tsx @@ -0,0 +1,60 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import { + noop, + normalizePage, + routeSelectors, + runPerfSelectorComputation, +} from '../shared' + +export const Route = createFileRoute('/items/$id')({ + params: { + parse: (params) => ({ + ...params, + id: normalizePage(params.id), + }), + stringify: (params) => ({ + ...params, + id: `${params.id}`, + }), + }, + onEnter: noop, + onStay: noop, + onLeave: noop, + component: ItemsPage, +}) + +export function ItemParamsSubscriber() { + const params = Route.useParams({ + select: (params) => runPerfSelectorComputation(params.id), + }) + + void runPerfSelectorComputation(params) + return null +} + +function ItemsPage() { + return ( + <> + {routeSelectors.map((selector) => ( + + ))} + + Details + + + Preserve search on item + + + + ) +} diff --git a/benchmarks/client-nav/react/src/routes/search.tsx b/benchmarks/client-nav/react/src/routes/search.tsx new file mode 100644 index 0000000000..b1d7a9134c --- /dev/null +++ b/benchmarks/client-nav/react/src/routes/search.tsx @@ -0,0 +1,98 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { + normalizeFilter, + normalizePage, + routeSelectors, + runPerfSelectorComputation, +} from '../shared' + +export const Route = createFileRoute('/search')({ + validateSearch: (search: Record) => ({ + page: normalizePage(search.page), + filter: normalizeFilter(search.filter), + }), + search: { + middlewares: [ + ({ search, next }) => { + const result = next(search) + return { + page: result.page, + filter: result.filter, + } + }, + ], + }, + loaderDeps: ({ search }) => ({ + page: search.page, + filter: search.filter, + }), + loader: ({ deps }) => ({ + seed: deps.page * 31 + deps.filter.length, + checksum: deps.page * 17 + deps.filter.length, + }), + staleTime: 60_000, + gcTime: 60_000, + component: SearchPage, +}) + +function SearchStateSubscriber() { + const search = Route.useSearch({ + select: (search) => + runPerfSelectorComputation(search.page + search.filter.length), + }) + + void runPerfSelectorComputation(search) + return null +} + +function SearchLoaderDepsSubscriber() { + const loaderDeps = Route.useLoaderDeps({ + select: (loaderDeps) => + runPerfSelectorComputation(loaderDeps.page + loaderDeps.filter.length), + }) + + void runPerfSelectorComputation(loaderDeps) + return null +} + +function SearchLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (loaderData) => + runPerfSelectorComputation(loaderData.seed + loaderData.checksum), + }) + + void runPerfSelectorComputation(loaderData) + return null +} + +function SearchPage() { + return ( + <> + {routeSelectors.map((selector) => ( + + ))} + {routeSelectors.map((selector) => ( + + ))} + {routeSelectors.map((selector) => ( + + ))} + ({ + page: prev.page + 1, + filter: prev.filter, + junk: 'local-updater', + })} + activeOptions={{ includeSearch: true }} + activeProps={{ className: 'active-link' }} + inactiveProps={{ className: 'inactive-link' }} + > + Next page + + + ) +} diff --git a/benchmarks/client-nav/react/src/shared.ts b/benchmarks/client-nav/react/src/shared.ts new file mode 100644 index 0000000000..25ba7891b3 --- /dev/null +++ b/benchmarks/client-nav/react/src/shared.ts @@ -0,0 +1,23 @@ +export function runPerfSelectorComputation(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 40; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function normalizePage(value: unknown) { + const page = Number(value) + return Number.isFinite(page) && page > 0 ? Math.trunc(page) : 1 +} + +export function normalizeFilter(value: unknown) { + return typeof value === 'string' && value.length > 0 ? value : 'all' +} + +export const noop = () => {} +export const rootSelectors = Array.from({ length: 10 }, (_, index) => index) +export const routeSelectors = Array.from({ length: 6 }, (_, index) => index) +export const linkGroups = Array.from({ length: 4 }, (_, index) => index) diff --git a/benchmarks/client-nav/react/tsconfig.json b/benchmarks/client-nav/react/tsconfig.json index 046a85af51..d91cc5b487 100644 --- a/benchmarks/client-nav/react/tsconfig.json +++ b/benchmarks/client-nav/react/tsconfig.json @@ -7,6 +7,8 @@ "types": ["node", "vite/client", "vitest/globals"] }, "include": [ + "src/**/*.ts", + "src/**/*.tsx", "speed.bench.ts", "speed.flame.ts", "../jsdom.ts", diff --git a/benchmarks/client-nav/react/vite.config.ts b/benchmarks/client-nav/react/vite.config.ts index ffa1d41be8..63b45cec37 100644 --- a/benchmarks/client-nav/react/vite.config.ts +++ b/benchmarks/client-nav/react/vite.config.ts @@ -1,7 +1,10 @@ +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import codspeedPlugin from '@codspeed/vitest-plugin' +const setupFile = fileURLToPath(new URL('../vitest.setup.ts', import.meta.url)) + export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify('production'), @@ -16,7 +19,7 @@ export default defineConfig({ emptyOutDir: true, minify: false, lib: { - entry: './react/app.tsx', + entry: './react/src/app.tsx', formats: ['es'], fileName: 'app', }, @@ -25,6 +28,6 @@ export default defineConfig({ name: '@benchmarks/client-nav (react)', watch: false, environment: 'jsdom', - setupFiles: ['./vitest.setup.ts'], + setupFiles: [setupFile], }, }) diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/project.json b/benchmarks/client-nav/scenarios/before-load-context/react/project.json new file mode 100644 index 0000000000..656f4b43cc --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/project.json @@ -0,0 +1,53 @@ +{ + "name": "@benchmarks/client-nav-before-load-context-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts" + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/setup.ts b/benchmarks/client-nav/scenarios/before-load-context/react/setup.ts new file mode 100644 index 0000000000..919fe29084 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createBeforeLoadContextWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createBeforeLoadContextWorkload('react', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/speed.bench.ts b/benchmarks/client-nav/scenarios/before-load-context/react/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/speed.flame.ts b/benchmarks/client-nav/scenarios/before-load-context/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/app.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/app.tsx new file mode 100644 index 0000000000..94885afdc1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/app.tsx @@ -0,0 +1,43 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { + createRootBenchmarkContext, + updateRootBenchmarkContext, +} from '../../shared' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const rootContext = createRootBenchmarkContext() + const router = getRouter(rootContext) + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + setRootSeed(seed: number) { + const version = updateRootBenchmarkContext(rootContext, seed) + router.update({ + ...router.options, + context: rootContext, + }) + + return version + }, + getRootVersion() { + return rootContext.version + }, + getBeforeLoadCounters() { + return rootContext.counters + }, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/before-load-context/react/src/routeTree.gen.ts new file mode 100644 index 0000000000..e029354908 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/routeTree.gen.ts @@ -0,0 +1,216 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AppRouteImport } from './routes/app' +import { Route as AppOrgIdRouteImport } from './routes/app.$orgId' +import { Route as AppOrgIdProjectsRouteImport } from './routes/app.$orgId.projects' +import { Route as AppOrgIdProjectsProjectIdRouteImport } from './routes/app.$orgId.projects.$projectId' +import { Route as AppOrgIdProjectsProjectIdTasksRouteImport } from './routes/app.$orgId.projects.$projectId.tasks' +import { Route as AppOrgIdProjectsProjectIdTasksTaskIdRouteImport } from './routes/app.$orgId.projects.$projectId.tasks.$taskId' + +const AppRoute = AppRouteImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) +const AppOrgIdRoute = AppOrgIdRouteImport.update({ + id: '/$orgId', + path: '/$orgId', + getParentRoute: () => AppRoute, +} as any) +const AppOrgIdProjectsRoute = AppOrgIdProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => AppOrgIdRoute, +} as any) +const AppOrgIdProjectsProjectIdRoute = AppOrgIdProjectsProjectIdRouteImport.update({ + id: '/$projectId', + path: '/$projectId', + getParentRoute: () => AppOrgIdProjectsRoute, +} as any) +const AppOrgIdProjectsProjectIdTasksRoute = AppOrgIdProjectsProjectIdTasksRouteImport.update({ + id: '/tasks', + path: '/tasks', + getParentRoute: () => AppOrgIdProjectsProjectIdRoute, +} as any) +const AppOrgIdProjectsProjectIdTasksTaskIdRoute = AppOrgIdProjectsProjectIdTasksTaskIdRouteImport.update({ + id: '/$taskId', + path: '/$taskId', + getParentRoute: () => AppOrgIdProjectsProjectIdTasksRoute, +} as any) + +export interface FileRoutesByFullPath { + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesByTo { + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + fileRoutesByTo: FileRoutesByTo + to: + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + id: + | '__root__' + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AppRoute: typeof AppRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/app/$orgId': { + id: '/app/$orgId' + path: '/$orgId' + fullPath: '/app/$orgId' + preLoaderRoute: typeof AppOrgIdRouteImport + parentRoute: typeof AppRoute + } + '/app/$orgId/projects': { + id: '/app/$orgId/projects' + path: '/projects' + fullPath: '/app/$orgId/projects' + preLoaderRoute: typeof AppOrgIdProjectsRouteImport + parentRoute: typeof AppOrgIdRoute + } + '/app/$orgId/projects/$projectId': { + id: '/app/$orgId/projects/$projectId' + path: '/$projectId' + fullPath: '/app/$orgId/projects/$projectId' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdRouteImport + parentRoute: typeof AppOrgIdProjectsRoute + } + '/app/$orgId/projects/$projectId/tasks': { + id: '/app/$orgId/projects/$projectId/tasks' + path: '/tasks' + fullPath: '/app/$orgId/projects/$projectId/tasks' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdTasksRouteImport + parentRoute: typeof AppOrgIdProjectsProjectIdRoute + } + '/app/$orgId/projects/$projectId/tasks/$taskId': { + id: '/app/$orgId/projects/$projectId/tasks/$taskId' + path: '/$taskId' + fullPath: '/app/$orgId/projects/$projectId/tasks/$taskId' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdTasksTaskIdRouteImport + parentRoute: typeof AppOrgIdProjectsProjectIdTasksRoute + } + } +} + +interface AppOrgIdProjectsProjectIdTasksRouteChildren { + AppOrgIdProjectsProjectIdTasksTaskIdRoute: typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} + +const AppOrgIdProjectsProjectIdTasksRouteChildren: AppOrgIdProjectsProjectIdTasksRouteChildren = { + AppOrgIdProjectsProjectIdTasksTaskIdRoute: AppOrgIdProjectsProjectIdTasksTaskIdRoute, +} + +const AppOrgIdProjectsProjectIdTasksRouteWithChildren = AppOrgIdProjectsProjectIdTasksRoute._addFileChildren( + AppOrgIdProjectsProjectIdTasksRouteChildren, +) + +interface AppOrgIdProjectsProjectIdRouteChildren { + AppOrgIdProjectsProjectIdTasksRoute: typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren +} + +const AppOrgIdProjectsProjectIdRouteChildren: AppOrgIdProjectsProjectIdRouteChildren = { + AppOrgIdProjectsProjectIdTasksRoute: AppOrgIdProjectsProjectIdTasksRouteWithChildren, +} + +const AppOrgIdProjectsProjectIdRouteWithChildren = AppOrgIdProjectsProjectIdRoute._addFileChildren( + AppOrgIdProjectsProjectIdRouteChildren, +) + +interface AppOrgIdProjectsRouteChildren { + AppOrgIdProjectsProjectIdRoute: typeof AppOrgIdProjectsProjectIdRouteWithChildren +} + +const AppOrgIdProjectsRouteChildren: AppOrgIdProjectsRouteChildren = { + AppOrgIdProjectsProjectIdRoute: AppOrgIdProjectsProjectIdRouteWithChildren, +} + +const AppOrgIdProjectsRouteWithChildren = AppOrgIdProjectsRoute._addFileChildren( + AppOrgIdProjectsRouteChildren, +) + +interface AppOrgIdRouteChildren { + AppOrgIdProjectsRoute: typeof AppOrgIdProjectsRouteWithChildren +} + +const AppOrgIdRouteChildren: AppOrgIdRouteChildren = { + AppOrgIdProjectsRoute: AppOrgIdProjectsRouteWithChildren, +} + +const AppOrgIdRouteWithChildren = AppOrgIdRoute._addFileChildren( + AppOrgIdRouteChildren, +) + +interface AppRouteChildren { + AppOrgIdRoute: typeof AppOrgIdRouteWithChildren +} + +const AppRouteChildren: AppRouteChildren = { + AppOrgIdRoute: AppOrgIdRouteWithChildren, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AppRoute: AppRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/router.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/router.tsx new file mode 100644 index 0000000000..9c6ff5802e --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/router.tsx @@ -0,0 +1,23 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { + buildTaskPath, + initialTaskTarget, + type RootBenchmarkContext, +} from '../../shared' +import { routeTree } from './routeTree.gen' + +export function getRouter(rootContext: RootBenchmarkContext) { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [buildTaskPath(initialTaskTarget)], + }), + routeTree, + context: rootContext, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/__root.tsx new file mode 100644 index 0000000000..c1ebabc8ff --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/__root.tsx @@ -0,0 +1,36 @@ +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' +import { + consumeSelectedValue, + rootSubscribers, + runContextComputation, + type RootBenchmarkContext, +} from '../../../shared' + +export const Route = createRootRouteWithContext()({ + component: RootComponent, +}) + +function RootContextSubscriber(props: { selector: number }) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.seed + context.version + props.selector, + context.authToken, + 10, + ), + }) + + consumeSelectedValue(value, 'root-context-subscriber') + return null +} + +function RootComponent() { + return ( + <> + {rootSubscribers.map((selector) => ( + + ))} + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx new file mode 100644 index 0000000000..7dc31105fd --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx @@ -0,0 +1,62 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + consumeSelectedValue, + deriveTaskContext, + leafSubscribers, + makeTaskChain, + runContextComputation, +} from '../../../shared' + +export const Route = createFileRoute( + '/app/$orgId/projects/$projectId/tasks/$taskId', +)({ + beforeLoad: ({ context, params }) => + deriveTaskContext(context, params.taskId), + component: TaskPage, +}) + +function TaskContextSubscriber(props: { selector: number }) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.taskChecksum + props.selector, + context.taskMarker, + 10, + ), + }) + + consumeSelectedValue(value, 'task-context-subscriber') + return null +} + +function TaskPage() { + const context = Route.useRouteContext({ + select: (context) => ({ + orgId: context.orgId, + projectId: context.projectId, + taskId: context.taskId, + contextVersion: context.contextVersion, + taskChecksum: context.taskChecksum, + taskMarker: context.taskMarker, + }), + }) + + return ( + <> + {leafSubscribers.map((selector) => ( + + ))} +
+ {context.taskMarker} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tasks.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tasks.tsx new file mode 100644 index 0000000000..fc9ed4cfdf --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tasks.tsx @@ -0,0 +1,21 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' +import { + consumeSelectedValue, + deriveTaskListContext, + runContextComputation, +} from '../../../shared' + +export const Route = createFileRoute('/app/$orgId/projects/$projectId/tasks')({ + beforeLoad: ({ context }) => deriveTaskListContext(context), + component: TasksLayout, +}) + +function TasksLayout() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation(context.taskListSeed, context.taskScope, 10), + }) + + consumeSelectedValue(value, 'tasks-context-subscriber') + return +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tsx new file mode 100644 index 0000000000..e7a46f946c --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.$projectId.tsx @@ -0,0 +1,41 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' +import { + consumeSelectedValue, + deriveProjectContext, + middleSubscribers, + runContextComputation, +} from '../../../shared' + +export const Route = createFileRoute('/app/$orgId/projects/$projectId')({ + beforeLoad: ({ context, params }) => + deriveProjectContext(context, params.projectId), + component: ProjectLayout, +}) + +function ProjectContextSubscriber(props: { selector: number }) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.projectChecksum + props.selector, + context.projectId, + 10, + ), + }) + + consumeSelectedValue(value, 'project-context-subscriber') + return null +} + +function ProjectLayout() { + return ( + <> + {middleSubscribers.map((selector) => ( + + ))} + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.tsx new file mode 100644 index 0000000000..155ce6c711 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.projects.tsx @@ -0,0 +1,25 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' +import { + consumeSelectedValue, + deriveProjectsContext, + runContextComputation, +} from '../../../shared' + +export const Route = createFileRoute('/app/$orgId/projects')({ + beforeLoad: ({ context }) => deriveProjectsContext(context), + component: ProjectsLayout, +}) + +function ProjectsLayout() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.projectIndexSeed, + context.breadcrumb[2], + 10, + ), + }) + + consumeSelectedValue(value, 'projects-context-subscriber') + return +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.tsx new file mode 100644 index 0000000000..0c50c02ed9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.$orgId.tsx @@ -0,0 +1,21 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' +import { + consumeSelectedValue, + deriveOrgContext, + runContextComputation, +} from '../../../shared' + +export const Route = createFileRoute('/app/$orgId')({ + beforeLoad: ({ context, params }) => deriveOrgContext(context, params.orgId), + component: OrgLayout, +}) + +function OrgLayout() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation(context.orgChecksum, context.orgPermissions[0], 10), + }) + + consumeSelectedValue(value, 'org-context-subscriber') + return +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.tsx b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.tsx new file mode 100644 index 0000000000..b983756d87 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/src/routes/app.tsx @@ -0,0 +1,37 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' +import { + consumeSelectedValue, + deriveTenantContext, + middleSubscribers, + runContextComputation, +} from '../../../shared' + +export const Route = createFileRoute('/app')({ + beforeLoad: ({ context }) => deriveTenantContext(context), + component: AppLayout, +}) + +function AppContextSubscriber(props: { selector: number }) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.tenantChecksum + props.selector, + context.tenantId, + 10, + ), + }) + + consumeSelectedValue(value, 'app-context-subscriber') + return null +} + +function AppLayout() { + return ( + <> + {middleSubscribers.map((selector) => ( + + ))} + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/tsconfig.json b/benchmarks/client-nav/scenarios/before-load-context/react/tsconfig.json new file mode 100644 index 0000000000..efd94fa8be --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/react/vite.config.ts b/benchmarks/client-nav/scenarios/before-load-context/react/vite.config.ts new file mode 100644 index 0000000000..b271643bb5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav before-load-context (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/shared.ts b/benchmarks/client-nav/scenarios/before-load-context/shared.ts new file mode 100644 index 0000000000..f067c5abb4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/shared.ts @@ -0,0 +1,371 @@ +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' + +export type BeforeLoadLevel = + | 'app' + | 'org' + | 'projects' + | 'project' + | 'tasks' + | 'task' + +export interface BeforeLoadCounters { + app: number + org: number + projects: number + project: number + tasks: number + task: number +} + +export interface RootBenchmarkContext { + seed: number + version: number + authToken: string + counters: BeforeLoadCounters +} + +export interface TenantContext { + tenantId: string + tenantChecksum: number + contextVersion: number +} + +export interface OrgContext { + orgId: string + orgPermissions: [string, string, string] + orgChecksum: number +} + +export interface ProjectsContext { + projectIndexSeed: number + breadcrumb: [string, string, string] +} + +export interface ProjectContext { + projectId: string + projectChecksum: number + projectFlags: { + canEdit: boolean + canArchive: boolean + } +} + +export interface TaskListContext { + taskListSeed: number + taskScope: string +} + +export interface TaskContext { + taskId: string + taskChecksum: number + taskMarker: string +} + +export interface TaskRouteTarget { + orgId: string + projectId: string + taskId: string +} + +export type BeforeLoadContextAction = + | { + type: 'navigate' + target: TaskRouteTarget + } + | { + type: 'invalidate' + rootSeed: number + } + +const beforeLoadLevels: Array = [ + 'app', + 'org', + 'projects', + 'project', + 'tasks', + 'task', +] + +export const rootSubscribers = Array.from({ length: 4 }, (_, index) => index) +export const middleSubscribers = Array.from({ length: 5 }, (_, index) => index) +export const leafSubscribers = Array.from({ length: 6 }, (_, index) => index) + +const rootSeed = 0x51f15e +const actionSeed = 0xbef05eed + +export const initialTaskTarget: TaskRouteTarget = { + orgId: 'org-initial', + projectId: 'project-initial', + taskId: 'task-initial', +} + +export function createBeforeLoadCounters(): BeforeLoadCounters { + return { + app: 0, + org: 0, + projects: 0, + project: 0, + tasks: 0, + task: 0, + } +} + +export function cloneBeforeLoadCounters(counters: BeforeLoadCounters) { + return { + app: counters.app, + org: counters.org, + projects: counters.projects, + project: counters.project, + tasks: counters.tasks, + task: counters.task, + } satisfies BeforeLoadCounters +} + +export function assertAllBeforeLoadLevels( + counters: BeforeLoadCounters, + label: string, +) { + for (const level of beforeLoadLevels) { + if (counters[level] <= 0) { + throw new Error(`${label} skipped beforeLoad level: ${level}`) + } + } +} + +export function assertBeforeLoadLevelsAdvanced( + before: BeforeLoadCounters, + after: BeforeLoadCounters, + label: string, +) { + for (const level of beforeLoadLevels) { + if (after[level] <= before[level]) { + throw new Error(`${label} did not recompute beforeLoad level: ${level}`) + } + } +} + +export function createRootBenchmarkContext(): RootBenchmarkContext { + return { + seed: rootSeed, + version: 0, + authToken: `auth-${rootSeed.toString(36)}`, + counters: createBeforeLoadCounters(), + } +} + +export function updateRootBenchmarkContext( + context: RootBenchmarkContext, + seed: number, +) { + context.seed = seed + context.version += 1 + context.authToken = `auth-${seed.toString(36)}-${context.version.toString(36)}` + + return context.version +} + +export function buildTaskPath(target: TaskRouteTarget) { + return `/app/${target.orgId}/projects/${target.projectId}/tasks/${target.taskId}` +} + +export function makeTaskChain(target: TaskRouteTarget) { + return `${target.orgId}/${target.projectId}/${target.taskId}` +} + +export function runContextComputation( + seed: number, + label: string, + rounds = 52, +) { + let value = Math.trunc(seed) >>> 0 + + for (let index = 0; index < label.length; index++) { + value = (value * 33 + label.charCodeAt(index) + index) >>> 0 + } + + for (let index = 0; index < rounds; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + value ^= value >>> 13 + } + + return value >>> 0 +} + +export function consumeSelectedValue(value: number, label: string) { + return runContextComputation(value, label, 12) +} + +export function deriveTenantContext( + context: RootBenchmarkContext, +): TenantContext { + context.counters.app += 1 + + const tenantChecksum = runContextComputation( + context.seed + context.version, + context.authToken, + ) + + return { + tenantId: `tenant-${tenantChecksum % 997}`, + tenantChecksum, + contextVersion: context.version, + } +} + +export function deriveOrgContext( + context: RootBenchmarkContext & TenantContext, + orgId: string, +): OrgContext { + context.counters.org += 1 + + const orgChecksum = runContextComputation( + context.tenantChecksum + orgId.length, + `${context.tenantId}:${orgId}`, + ) + + return { + orgId, + orgPermissions: [ + `read:${orgId}`, + `write:${orgChecksum % 17}`, + `tenant:${context.tenantId}`, + ], + orgChecksum, + } +} + +export function deriveProjectsContext( + context: RootBenchmarkContext & TenantContext & OrgContext, +): ProjectsContext { + context.counters.projects += 1 + + const projectIndexSeed = runContextComputation( + context.orgChecksum + context.orgPermissions.length, + `${context.orgId}:projects`, + ) + + return { + projectIndexSeed, + breadcrumb: [context.tenantId, context.orgId, 'projects'], + } +} + +export function deriveProjectContext( + context: RootBenchmarkContext & TenantContext & OrgContext & ProjectsContext, + projectId: string, +): ProjectContext { + context.counters.project += 1 + + const projectChecksum = runContextComputation( + context.projectIndexSeed + projectId.length, + `${context.orgId}:${projectId}`, + ) + + return { + projectId, + projectChecksum, + projectFlags: { + canEdit: projectChecksum % 2 === 0, + canArchive: projectChecksum % 5 === 0, + }, + } +} + +export function deriveTaskListContext( + context: RootBenchmarkContext & OrgContext & ProjectContext, +): TaskListContext { + context.counters.tasks += 1 + + const taskListSeed = runContextComputation( + context.projectChecksum + context.orgChecksum, + `${context.projectId}:tasks`, + ) + + return { + taskListSeed, + taskScope: `${context.orgId}:${context.projectId}`, + } +} + +export function deriveTaskContext( + context: RootBenchmarkContext & + TenantContext & + OrgContext & + ProjectContext & + TaskListContext, + taskId: string, +): TaskContext { + context.counters.task += 1 + + const taskChecksum = runContextComputation( + context.taskListSeed + taskId.length, + `${context.taskScope}:${taskId}`, + ) + + return { + taskId, + taskChecksum, + taskMarker: `${context.orgId}/${context.projectId}/${taskId}/${context.contextVersion}/${taskChecksum}`, + } +} + +function createTaskTarget( + random: () => number, + cycleIndex: number, + prefix: string, + orgId?: string, + projectId?: string, +): TaskRouteTarget { + const suffix = cycleIndex.toString(36) + + return { + orgId: orgId ?? `org-${prefix}-${randomSegment(random)}-${suffix}`, + projectId: + projectId ?? `project-${prefix}-${randomSegment(random)}-${suffix}`, + taskId: `task-${prefix}-${randomSegment(random)}-${suffix}`, + } +} + +function createActionCycle( + random: () => number, + cycleIndex: number, +): Array { + const first = createTaskTarget(random, cycleIndex, 'a') + const sameProject = createTaskTarget( + random, + cycleIndex, + 'b', + first.orgId, + first.projectId, + ) + const sameOrg = createTaskTarget(random, cycleIndex, 'c', first.orgId) + const nextOrg = createTaskTarget(random, cycleIndex, 'd') + const backToFirstOrg = createTaskTarget(random, cycleIndex, 'e', first.orgId) + const rootSeedForCycle = + 10_000 + Math.floor(random() * 1_000_000) + cycleIndex + + return [ + { type: 'navigate', target: first }, + { type: 'navigate', target: sameProject }, + { type: 'navigate', target: sameOrg }, + { type: 'navigate', target: nextOrg }, + { type: 'invalidate', rootSeed: rootSeedForCycle }, + { type: 'navigate', target: backToFirstOrg }, + ] +} + +function createBeforeLoadContextActions() { + const random = createDeterministicRandom(actionSeed) + const actions: Array = [] + + for (let cycleIndex = 0; cycleIndex < 3; cycleIndex++) { + actions.push(...createActionCycle(random, cycleIndex)) + } + + return actions +} + +export const beforeLoadContextActions = createBeforeLoadContextActions() diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/project.json b/benchmarks/client-nav/scenarios/before-load-context/solid/project.json new file mode 100644 index 0000000000..0018d878cd --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/project.json @@ -0,0 +1,53 @@ +{ + "name": "@benchmarks/client-nav-before-load-context-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts" + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/setup.ts b/benchmarks/client-nav/scenarios/before-load-context/solid/setup.ts new file mode 100644 index 0000000000..ce45b6967f --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createBeforeLoadContextWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createBeforeLoadContextWorkload('solid', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/before-load-context/solid/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/before-load-context/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/app.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/app.tsx new file mode 100644 index 0000000000..bfae48acac --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/app.tsx @@ -0,0 +1,41 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { + createRootBenchmarkContext, + updateRootBenchmarkContext, +} from '../../shared' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const rootContext = createRootBenchmarkContext() + const router = getRouter(rootContext) + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + setRootSeed(seed: number) { + const version = updateRootBenchmarkContext(rootContext, seed) + router.update({ + ...router.options, + context: rootContext, + }) + + return version + }, + getRootVersion() { + return rootContext.version + }, + getBeforeLoadCounters() { + return rootContext.counters + }, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routeTree.gen.ts new file mode 100644 index 0000000000..dde3bebb49 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routeTree.gen.ts @@ -0,0 +1,216 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AppRouteImport } from './routes/app' +import { Route as AppOrgIdRouteImport } from './routes/app.$orgId' +import { Route as AppOrgIdProjectsRouteImport } from './routes/app.$orgId.projects' +import { Route as AppOrgIdProjectsProjectIdRouteImport } from './routes/app.$orgId.projects.$projectId' +import { Route as AppOrgIdProjectsProjectIdTasksRouteImport } from './routes/app.$orgId.projects.$projectId.tasks' +import { Route as AppOrgIdProjectsProjectIdTasksTaskIdRouteImport } from './routes/app.$orgId.projects.$projectId.tasks.$taskId' + +const AppRoute = AppRouteImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) +const AppOrgIdRoute = AppOrgIdRouteImport.update({ + id: '/$orgId', + path: '/$orgId', + getParentRoute: () => AppRoute, +} as any) +const AppOrgIdProjectsRoute = AppOrgIdProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => AppOrgIdRoute, +} as any) +const AppOrgIdProjectsProjectIdRoute = AppOrgIdProjectsProjectIdRouteImport.update({ + id: '/$projectId', + path: '/$projectId', + getParentRoute: () => AppOrgIdProjectsRoute, +} as any) +const AppOrgIdProjectsProjectIdTasksRoute = AppOrgIdProjectsProjectIdTasksRouteImport.update({ + id: '/tasks', + path: '/tasks', + getParentRoute: () => AppOrgIdProjectsProjectIdRoute, +} as any) +const AppOrgIdProjectsProjectIdTasksTaskIdRoute = AppOrgIdProjectsProjectIdTasksTaskIdRouteImport.update({ + id: '/$taskId', + path: '/$taskId', + getParentRoute: () => AppOrgIdProjectsProjectIdTasksRoute, +} as any) + +export interface FileRoutesByFullPath { + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesByTo { + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + fileRoutesByTo: FileRoutesByTo + to: + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + id: + | '__root__' + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AppRoute: typeof AppRouteWithChildren +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/app/$orgId': { + id: '/app/$orgId' + path: '/$orgId' + fullPath: '/app/$orgId' + preLoaderRoute: typeof AppOrgIdRouteImport + parentRoute: typeof AppRoute + } + '/app/$orgId/projects': { + id: '/app/$orgId/projects' + path: '/projects' + fullPath: '/app/$orgId/projects' + preLoaderRoute: typeof AppOrgIdProjectsRouteImport + parentRoute: typeof AppOrgIdRoute + } + '/app/$orgId/projects/$projectId': { + id: '/app/$orgId/projects/$projectId' + path: '/$projectId' + fullPath: '/app/$orgId/projects/$projectId' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdRouteImport + parentRoute: typeof AppOrgIdProjectsRoute + } + '/app/$orgId/projects/$projectId/tasks': { + id: '/app/$orgId/projects/$projectId/tasks' + path: '/tasks' + fullPath: '/app/$orgId/projects/$projectId/tasks' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdTasksRouteImport + parentRoute: typeof AppOrgIdProjectsProjectIdRoute + } + '/app/$orgId/projects/$projectId/tasks/$taskId': { + id: '/app/$orgId/projects/$projectId/tasks/$taskId' + path: '/$taskId' + fullPath: '/app/$orgId/projects/$projectId/tasks/$taskId' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdTasksTaskIdRouteImport + parentRoute: typeof AppOrgIdProjectsProjectIdTasksRoute + } + } +} + +interface AppOrgIdProjectsProjectIdTasksRouteChildren { + AppOrgIdProjectsProjectIdTasksTaskIdRoute: typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} + +const AppOrgIdProjectsProjectIdTasksRouteChildren: AppOrgIdProjectsProjectIdTasksRouteChildren = { + AppOrgIdProjectsProjectIdTasksTaskIdRoute: AppOrgIdProjectsProjectIdTasksTaskIdRoute, +} + +const AppOrgIdProjectsProjectIdTasksRouteWithChildren = AppOrgIdProjectsProjectIdTasksRoute._addFileChildren( + AppOrgIdProjectsProjectIdTasksRouteChildren, +) + +interface AppOrgIdProjectsProjectIdRouteChildren { + AppOrgIdProjectsProjectIdTasksRoute: typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren +} + +const AppOrgIdProjectsProjectIdRouteChildren: AppOrgIdProjectsProjectIdRouteChildren = { + AppOrgIdProjectsProjectIdTasksRoute: AppOrgIdProjectsProjectIdTasksRouteWithChildren, +} + +const AppOrgIdProjectsProjectIdRouteWithChildren = AppOrgIdProjectsProjectIdRoute._addFileChildren( + AppOrgIdProjectsProjectIdRouteChildren, +) + +interface AppOrgIdProjectsRouteChildren { + AppOrgIdProjectsProjectIdRoute: typeof AppOrgIdProjectsProjectIdRouteWithChildren +} + +const AppOrgIdProjectsRouteChildren: AppOrgIdProjectsRouteChildren = { + AppOrgIdProjectsProjectIdRoute: AppOrgIdProjectsProjectIdRouteWithChildren, +} + +const AppOrgIdProjectsRouteWithChildren = AppOrgIdProjectsRoute._addFileChildren( + AppOrgIdProjectsRouteChildren, +) + +interface AppOrgIdRouteChildren { + AppOrgIdProjectsRoute: typeof AppOrgIdProjectsRouteWithChildren +} + +const AppOrgIdRouteChildren: AppOrgIdRouteChildren = { + AppOrgIdProjectsRoute: AppOrgIdProjectsRouteWithChildren, +} + +const AppOrgIdRouteWithChildren = AppOrgIdRoute._addFileChildren( + AppOrgIdRouteChildren, +) + +interface AppRouteChildren { + AppOrgIdRoute: typeof AppOrgIdRouteWithChildren +} + +const AppRouteChildren: AppRouteChildren = { + AppOrgIdRoute: AppOrgIdRouteWithChildren, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AppRoute: AppRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/router.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/router.tsx new file mode 100644 index 0000000000..10d3370c12 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/router.tsx @@ -0,0 +1,23 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { + buildTaskPath, + initialTaskTarget, + type RootBenchmarkContext, +} from '../../shared' +import { routeTree } from './routeTree.gen' + +export function getRouter(rootContext: RootBenchmarkContext) { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [buildTaskPath(initialTaskTarget)], + }), + routeTree, + context: rootContext, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..06d0a4dc67 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/__root.tsx @@ -0,0 +1,41 @@ +import { For } from 'solid-js' +import { Outlet, createRootRouteWithContext } from '@tanstack/solid-router' +import { + consumeSelectedValue, + rootSubscribers, + runContextComputation, + type RootBenchmarkContext, +} from '../../../shared' +import { PerfValue } from '../runtime' + +export const Route = createRootRouteWithContext()({ + component: RootComponent, +}) + +function RootContextSubscriber(props: { selector: number }) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.seed + context.version + props.selector, + context.authToken, + 10, + ), + }) + + return ( + consumeSelectedValue(value(), 'root-context-subscriber')} + /> + ) +} + +function RootComponent() { + return ( + <> + + {(selector) => } + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx new file mode 100644 index 0000000000..303bb523c6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx @@ -0,0 +1,67 @@ +import { For } from 'solid-js' +import { createFileRoute } from '@tanstack/solid-router' +import { + consumeSelectedValue, + deriveTaskContext, + leafSubscribers, + makeTaskChain, + runContextComputation, +} from '../../../shared' +import { PerfValue } from '../runtime' + +export const Route = createFileRoute( + '/app/$orgId/projects/$projectId/tasks/$taskId', +)({ + beforeLoad: ({ context, params }) => + deriveTaskContext(context, params.taskId), + component: TaskPage, +}) + +function TaskContextSubscriber(props: { selector: number }) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.taskChecksum + props.selector, + context.taskMarker, + 10, + ), + }) + + return ( + consumeSelectedValue(value(), 'task-context-subscriber')} + /> + ) +} + +function TaskPage() { + const context = Route.useRouteContext({ + select: (context) => ({ + orgId: context.orgId, + projectId: context.projectId, + taskId: context.taskId, + contextVersion: context.contextVersion, + taskChecksum: context.taskChecksum, + taskMarker: context.taskMarker, + }), + }) + + return ( + <> + + {(selector) => } + +
+ {context().taskMarker} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tasks.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tasks.tsx new file mode 100644 index 0000000000..37b7400b26 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tasks.tsx @@ -0,0 +1,28 @@ +import { Outlet, createFileRoute } from '@tanstack/solid-router' +import { + consumeSelectedValue, + deriveTaskListContext, + runContextComputation, +} from '../../../shared' +import { PerfValue } from '../runtime' + +export const Route = createFileRoute('/app/$orgId/projects/$projectId/tasks')({ + beforeLoad: ({ context }) => deriveTaskListContext(context), + component: TasksLayout, +}) + +function TasksLayout() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation(context.taskListSeed, context.taskScope, 10), + }) + + return ( + <> + consumeSelectedValue(value(), 'tasks-context-subscriber')} + /> + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tsx new file mode 100644 index 0000000000..f598c7d039 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.$projectId.tsx @@ -0,0 +1,43 @@ +import { For } from 'solid-js' +import { Outlet, createFileRoute } from '@tanstack/solid-router' +import { + consumeSelectedValue, + deriveProjectContext, + middleSubscribers, + runContextComputation, +} from '../../../shared' +import { PerfValue } from '../runtime' + +export const Route = createFileRoute('/app/$orgId/projects/$projectId')({ + beforeLoad: ({ context, params }) => + deriveProjectContext(context, params.projectId), + component: ProjectLayout, +}) + +function ProjectContextSubscriber(props: { selector: number }) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.projectChecksum + props.selector, + context.projectId, + 10, + ), + }) + + return ( + consumeSelectedValue(value(), 'project-context-subscriber')} + /> + ) +} + +function ProjectLayout() { + return ( + <> + + {(selector) => } + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.tsx new file mode 100644 index 0000000000..750553de41 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.projects.tsx @@ -0,0 +1,34 @@ +import { Outlet, createFileRoute } from '@tanstack/solid-router' +import { + consumeSelectedValue, + deriveProjectsContext, + runContextComputation, +} from '../../../shared' +import { PerfValue } from '../runtime' + +export const Route = createFileRoute('/app/$orgId/projects')({ + beforeLoad: ({ context }) => deriveProjectsContext(context), + component: ProjectsLayout, +}) + +function ProjectsLayout() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.projectIndexSeed, + context.breadcrumb[2], + 10, + ), + }) + + return ( + <> + + consumeSelectedValue(value(), 'projects-context-subscriber') + } + /> + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.tsx new file mode 100644 index 0000000000..d25a667556 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.$orgId.tsx @@ -0,0 +1,28 @@ +import { Outlet, createFileRoute } from '@tanstack/solid-router' +import { + consumeSelectedValue, + deriveOrgContext, + runContextComputation, +} from '../../../shared' +import { PerfValue } from '../runtime' + +export const Route = createFileRoute('/app/$orgId')({ + beforeLoad: ({ context, params }) => deriveOrgContext(context, params.orgId), + component: OrgLayout, +}) + +function OrgLayout() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation(context.orgChecksum, context.orgPermissions[0], 10), + }) + + return ( + <> + consumeSelectedValue(value(), 'org-context-subscriber')} + /> + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.tsx new file mode 100644 index 0000000000..4f90398a5f --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/routes/app.tsx @@ -0,0 +1,42 @@ +import { For } from 'solid-js' +import { Outlet, createFileRoute } from '@tanstack/solid-router' +import { + consumeSelectedValue, + deriveTenantContext, + middleSubscribers, + runContextComputation, +} from '../../../shared' +import { PerfValue } from '../runtime' + +export const Route = createFileRoute('/app')({ + beforeLoad: ({ context }) => deriveTenantContext(context), + component: AppLayout, +}) + +function AppContextSubscriber(props: { selector: number }) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.tenantChecksum + props.selector, + context.tenantId, + 10, + ), + }) + + return ( + consumeSelectedValue(value(), 'app-context-subscriber')} + /> + ) +} + +function AppLayout() { + return ( + <> + + {(selector) => } + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/src/runtime.tsx b/benchmarks/client-nav/scenarios/before-load-context/solid/src/runtime.tsx new file mode 100644 index 0000000000..07e06da1c7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/src/runtime.tsx @@ -0,0 +1,9 @@ +import { createRenderEffect } from 'solid-js' + +export function PerfValue(props: { value: () => unknown }) { + createRenderEffect(() => { + void props.value() + }) + + return null +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/tsconfig.json b/benchmarks/client-nav/scenarios/before-load-context/solid/tsconfig.json new file mode 100644 index 0000000000..512dc3979f --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/solid/vite.config.ts b/benchmarks/client-nav/scenarios/before-load-context/solid/vite.config.ts new file mode 100644 index 0000000000..09ca2d7122 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/solid/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav before-load-context (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/project.json b/benchmarks/client-nav/scenarios/before-load-context/vue/project.json new file mode 100644 index 0000000000..cc25698c97 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/project.json @@ -0,0 +1,53 @@ +{ + "name": "@benchmarks/client-nav-before-load-context-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts" + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/setup.ts b/benchmarks/client-nav/scenarios/before-load-context/vue/setup.ts new file mode 100644 index 0000000000..2c7329daef --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createBeforeLoadContextWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createBeforeLoadContextWorkload('vue', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/before-load-context/vue/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/before-load-context/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/app.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/app.tsx new file mode 100644 index 0000000000..6c9cd5c20d --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/app.tsx @@ -0,0 +1,47 @@ +import * as Vue from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { + createRootBenchmarkContext, + updateRootBenchmarkContext, +} from '../../shared' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const rootContext = createRootBenchmarkContext() + const router = getRouter(rootContext) + const vueApp = Vue.createApp({ + setup() { + return () => + }, + }) + let didUnmount = false + + vueApp.mount(container) + + return { + router, + setRootSeed(seed: number) { + const version = updateRootBenchmarkContext(rootContext, seed) + router.update({ + ...router.options, + context: rootContext, + }) + + return version + }, + getRootVersion() { + return rootContext.version + }, + getBeforeLoadCounters() { + return rootContext.counters + }, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + vueApp.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routeTree.gen.ts new file mode 100644 index 0000000000..033d13c881 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routeTree.gen.ts @@ -0,0 +1,216 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AppRouteImport } from './routes/app' +import { Route as AppOrgIdRouteImport } from './routes/app.$orgId' +import { Route as AppOrgIdProjectsRouteImport } from './routes/app.$orgId.projects' +import { Route as AppOrgIdProjectsProjectIdRouteImport } from './routes/app.$orgId.projects.$projectId' +import { Route as AppOrgIdProjectsProjectIdTasksRouteImport } from './routes/app.$orgId.projects.$projectId.tasks' +import { Route as AppOrgIdProjectsProjectIdTasksTaskIdRouteImport } from './routes/app.$orgId.projects.$projectId.tasks.$taskId' + +const AppRoute = AppRouteImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) +const AppOrgIdRoute = AppOrgIdRouteImport.update({ + id: '/$orgId', + path: '/$orgId', + getParentRoute: () => AppRoute, +} as any) +const AppOrgIdProjectsRoute = AppOrgIdProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => AppOrgIdRoute, +} as any) +const AppOrgIdProjectsProjectIdRoute = AppOrgIdProjectsProjectIdRouteImport.update({ + id: '/$projectId', + path: '/$projectId', + getParentRoute: () => AppOrgIdProjectsRoute, +} as any) +const AppOrgIdProjectsProjectIdTasksRoute = AppOrgIdProjectsProjectIdTasksRouteImport.update({ + id: '/tasks', + path: '/tasks', + getParentRoute: () => AppOrgIdProjectsProjectIdRoute, +} as any) +const AppOrgIdProjectsProjectIdTasksTaskIdRoute = AppOrgIdProjectsProjectIdTasksTaskIdRouteImport.update({ + id: '/$taskId', + path: '/$taskId', + getParentRoute: () => AppOrgIdProjectsProjectIdTasksRoute, +} as any) + +export interface FileRoutesByFullPath { + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesByTo { + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/app': typeof AppRouteWithChildren + '/app/$orgId': typeof AppOrgIdRouteWithChildren + '/app/$orgId/projects': typeof AppOrgIdProjectsRouteWithChildren + '/app/$orgId/projects/$projectId': typeof AppOrgIdProjectsProjectIdRouteWithChildren + '/app/$orgId/projects/$projectId/tasks': typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren + '/app/$orgId/projects/$projectId/tasks/$taskId': typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + fileRoutesByTo: FileRoutesByTo + to: + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + id: + | '__root__' + | '/app' + | '/app/$orgId' + | '/app/$orgId/projects' + | '/app/$orgId/projects/$projectId' + | '/app/$orgId/projects/$projectId/tasks' + | '/app/$orgId/projects/$projectId/tasks/$taskId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AppRoute: typeof AppRouteWithChildren +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/app/$orgId': { + id: '/app/$orgId' + path: '/$orgId' + fullPath: '/app/$orgId' + preLoaderRoute: typeof AppOrgIdRouteImport + parentRoute: typeof AppRoute + } + '/app/$orgId/projects': { + id: '/app/$orgId/projects' + path: '/projects' + fullPath: '/app/$orgId/projects' + preLoaderRoute: typeof AppOrgIdProjectsRouteImport + parentRoute: typeof AppOrgIdRoute + } + '/app/$orgId/projects/$projectId': { + id: '/app/$orgId/projects/$projectId' + path: '/$projectId' + fullPath: '/app/$orgId/projects/$projectId' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdRouteImport + parentRoute: typeof AppOrgIdProjectsRoute + } + '/app/$orgId/projects/$projectId/tasks': { + id: '/app/$orgId/projects/$projectId/tasks' + path: '/tasks' + fullPath: '/app/$orgId/projects/$projectId/tasks' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdTasksRouteImport + parentRoute: typeof AppOrgIdProjectsProjectIdRoute + } + '/app/$orgId/projects/$projectId/tasks/$taskId': { + id: '/app/$orgId/projects/$projectId/tasks/$taskId' + path: '/$taskId' + fullPath: '/app/$orgId/projects/$projectId/tasks/$taskId' + preLoaderRoute: typeof AppOrgIdProjectsProjectIdTasksTaskIdRouteImport + parentRoute: typeof AppOrgIdProjectsProjectIdTasksRoute + } + } +} + +interface AppOrgIdProjectsProjectIdTasksRouteChildren { + AppOrgIdProjectsProjectIdTasksTaskIdRoute: typeof AppOrgIdProjectsProjectIdTasksTaskIdRoute +} + +const AppOrgIdProjectsProjectIdTasksRouteChildren: AppOrgIdProjectsProjectIdTasksRouteChildren = { + AppOrgIdProjectsProjectIdTasksTaskIdRoute: AppOrgIdProjectsProjectIdTasksTaskIdRoute, +} + +const AppOrgIdProjectsProjectIdTasksRouteWithChildren = AppOrgIdProjectsProjectIdTasksRoute._addFileChildren( + AppOrgIdProjectsProjectIdTasksRouteChildren, +) + +interface AppOrgIdProjectsProjectIdRouteChildren { + AppOrgIdProjectsProjectIdTasksRoute: typeof AppOrgIdProjectsProjectIdTasksRouteWithChildren +} + +const AppOrgIdProjectsProjectIdRouteChildren: AppOrgIdProjectsProjectIdRouteChildren = { + AppOrgIdProjectsProjectIdTasksRoute: AppOrgIdProjectsProjectIdTasksRouteWithChildren, +} + +const AppOrgIdProjectsProjectIdRouteWithChildren = AppOrgIdProjectsProjectIdRoute._addFileChildren( + AppOrgIdProjectsProjectIdRouteChildren, +) + +interface AppOrgIdProjectsRouteChildren { + AppOrgIdProjectsProjectIdRoute: typeof AppOrgIdProjectsProjectIdRouteWithChildren +} + +const AppOrgIdProjectsRouteChildren: AppOrgIdProjectsRouteChildren = { + AppOrgIdProjectsProjectIdRoute: AppOrgIdProjectsProjectIdRouteWithChildren, +} + +const AppOrgIdProjectsRouteWithChildren = AppOrgIdProjectsRoute._addFileChildren( + AppOrgIdProjectsRouteChildren, +) + +interface AppOrgIdRouteChildren { + AppOrgIdProjectsRoute: typeof AppOrgIdProjectsRouteWithChildren +} + +const AppOrgIdRouteChildren: AppOrgIdRouteChildren = { + AppOrgIdProjectsRoute: AppOrgIdProjectsRouteWithChildren, +} + +const AppOrgIdRouteWithChildren = AppOrgIdRoute._addFileChildren( + AppOrgIdRouteChildren, +) + +interface AppRouteChildren { + AppOrgIdRoute: typeof AppOrgIdRouteWithChildren +} + +const AppRouteChildren: AppRouteChildren = { + AppOrgIdRoute: AppOrgIdRouteWithChildren, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AppRoute: AppRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/router.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/router.tsx new file mode 100644 index 0000000000..d434a5f813 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/router.tsx @@ -0,0 +1,23 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { + buildTaskPath, + initialTaskTarget, + type RootBenchmarkContext, +} from '../../shared' +import { routeTree } from './routeTree.gen' + +export function getRouter(rootContext: RootBenchmarkContext) { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [buildTaskPath(initialTaskTarget)], + }), + routeTree, + context: rootContext, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..709d36ee75 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/__root.tsx @@ -0,0 +1,49 @@ +import * as Vue from 'vue' +import { Outlet, createRootRouteWithContext } from '@tanstack/vue-router' +import { + consumeSelectedValue, + rootSubscribers, + runContextComputation, + type RootBenchmarkContext, +} from '../../../shared' + +const RootContextSubscriber = Vue.defineComponent({ + props: { + selector: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.seed + context.version + props.selector, + context.authToken, + 10, + ), + }) + + return () => { + consumeSelectedValue(value.value, 'root-context-subscriber') + return null + } + }, +}) + +const RootComponent = Vue.defineComponent({ + setup() { + return () => ( + <> + {rootSubscribers.map((selector) => ( + + ))} + + + ) + }, +}) + +export const Route = createRootRouteWithContext()({ + component: RootComponent, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx new file mode 100644 index 0000000000..a7d6ba0023 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tasks.$taskId.tsx @@ -0,0 +1,82 @@ +import * as Vue from 'vue' +import { createFileRoute } from '@tanstack/vue-router' +import { + consumeSelectedValue, + deriveTaskContext, + leafSubscribers, + makeTaskChain, + runContextComputation, +} from '../../../shared' + +const TaskContextSubscriber = Vue.defineComponent({ + props: { + selector: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.taskChecksum + props.selector, + context.taskMarker, + 10, + ), + }) + + return () => { + consumeSelectedValue(value.value, 'task-context-subscriber') + return null + } + }, +}) + +const TaskPage = Vue.defineComponent({ + setup() { + const context = Route.useRouteContext({ + select: (context) => ({ + orgId: context.orgId, + projectId: context.projectId, + taskId: context.taskId, + contextVersion: context.contextVersion, + taskChecksum: context.taskChecksum, + taskMarker: context.taskMarker, + }), + }) + + return () => { + const current = context.value + + return ( + <> + {leafSubscribers.map((selector) => ( + + ))} +
+ {current.taskMarker} +
+ + ) + } + }, +}) + +export const Route = createFileRoute( + '/app/$orgId/projects/$projectId/tasks/$taskId', +)({ + beforeLoad: ({ context, params }) => + deriveTaskContext(context, params.taskId), + component: TaskPage, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tasks.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tasks.tsx new file mode 100644 index 0000000000..dcd0b29861 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tasks.tsx @@ -0,0 +1,26 @@ +import * as Vue from 'vue' +import { Outlet, createFileRoute } from '@tanstack/vue-router' +import { + consumeSelectedValue, + deriveTaskListContext, + runContextComputation, +} from '../../../shared' + +const TasksLayout = Vue.defineComponent({ + setup() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation(context.taskListSeed, context.taskScope, 10), + }) + + return () => { + consumeSelectedValue(value.value, 'tasks-context-subscriber') + return + } + }, +}) + +export const Route = createFileRoute('/app/$orgId/projects/$projectId/tasks')({ + beforeLoad: ({ context }) => deriveTaskListContext(context), + component: TasksLayout, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tsx new file mode 100644 index 0000000000..439d639731 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.$projectId.tsx @@ -0,0 +1,54 @@ +import * as Vue from 'vue' +import { Outlet, createFileRoute } from '@tanstack/vue-router' +import { + consumeSelectedValue, + deriveProjectContext, + middleSubscribers, + runContextComputation, +} from '../../../shared' + +const ProjectContextSubscriber = Vue.defineComponent({ + props: { + selector: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.projectChecksum + props.selector, + context.projectId, + 10, + ), + }) + + return () => { + consumeSelectedValue(value.value, 'project-context-subscriber') + return null + } + }, +}) + +const ProjectLayout = Vue.defineComponent({ + setup() { + return () => ( + <> + {middleSubscribers.map((selector) => ( + + ))} + + + ) + }, +}) + +export const Route = createFileRoute('/app/$orgId/projects/$projectId')({ + beforeLoad: ({ context, params }) => + deriveProjectContext(context, params.projectId), + component: ProjectLayout, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.tsx new file mode 100644 index 0000000000..edf880eee0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.projects.tsx @@ -0,0 +1,30 @@ +import * as Vue from 'vue' +import { Outlet, createFileRoute } from '@tanstack/vue-router' +import { + consumeSelectedValue, + deriveProjectsContext, + runContextComputation, +} from '../../../shared' + +const ProjectsLayout = Vue.defineComponent({ + setup() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.projectIndexSeed, + context.breadcrumb[2], + 10, + ), + }) + + return () => { + consumeSelectedValue(value.value, 'projects-context-subscriber') + return + } + }, +}) + +export const Route = createFileRoute('/app/$orgId/projects')({ + beforeLoad: ({ context }) => deriveProjectsContext(context), + component: ProjectsLayout, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.tsx new file mode 100644 index 0000000000..02186c90b9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.$orgId.tsx @@ -0,0 +1,30 @@ +import * as Vue from 'vue' +import { Outlet, createFileRoute } from '@tanstack/vue-router' +import { + consumeSelectedValue, + deriveOrgContext, + runContextComputation, +} from '../../../shared' + +const OrgLayout = Vue.defineComponent({ + setup() { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.orgChecksum, + context.orgPermissions[0], + 10, + ), + }) + + return () => { + consumeSelectedValue(value.value, 'org-context-subscriber') + return + } + }, +}) + +export const Route = createFileRoute('/app/$orgId')({ + beforeLoad: ({ context, params }) => deriveOrgContext(context, params.orgId), + component: OrgLayout, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.tsx b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.tsx new file mode 100644 index 0000000000..bfda16a74b --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/src/routes/app.tsx @@ -0,0 +1,50 @@ +import * as Vue from 'vue' +import { Outlet, createFileRoute } from '@tanstack/vue-router' +import { + consumeSelectedValue, + deriveTenantContext, + middleSubscribers, + runContextComputation, +} from '../../../shared' + +const AppContextSubscriber = Vue.defineComponent({ + props: { + selector: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = Route.useRouteContext({ + select: (context) => + runContextComputation( + context.tenantChecksum + props.selector, + context.tenantId, + 10, + ), + }) + + return () => { + consumeSelectedValue(value.value, 'app-context-subscriber') + return null + } + }, +}) + +const AppLayout = Vue.defineComponent({ + setup() { + return () => ( + <> + {middleSubscribers.map((selector) => ( + + ))} + + + ) + }, +}) + +export const Route = createFileRoute('/app')({ + beforeLoad: ({ context }) => deriveTenantContext(context), + component: AppLayout, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/tsconfig.json b/benchmarks/client-nav/scenarios/before-load-context/vue/tsconfig.json new file mode 100644 index 0000000000..a6ca3ff61d --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/before-load-context/vue/vite.config.ts b/benchmarks/client-nav/scenarios/before-load-context/vue/vite.config.ts new file mode 100644 index 0000000000..7e342be7b7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav before-load-context (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/before-load-context/workload.ts b/benchmarks/client-nav/scenarios/before-load-context/workload.ts new file mode 100644 index 0000000000..21ef488a25 --- /dev/null +++ b/benchmarks/client-nav/scenarios/before-load-context/workload.ts @@ -0,0 +1,220 @@ +import type { AnyRouter } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import { + createClientNavLifecycle, + warnClientNavDevMode, + type Framework, + type MountedTestApp, +} from '#client-nav/lifecycle' +import { + assertAllBeforeLoadLevels, + assertBeforeLoadLevelsAdvanced, + beforeLoadContextActions, + cloneBeforeLoadCounters, + initialTaskTarget, + makeTaskChain, + type BeforeLoadCounters, + type TaskRouteTarget, +} from './shared' + +export interface MountedBeforeLoadContextApp< + TRouter extends AnyRouter = AnyRouter, +> extends MountedTestApp { + setRootSeed: (seed: number) => number + getRootVersion: () => number + getBeforeLoadCounters: () => BeforeLoadCounters +} + +export type BeforeLoadContextMountTestApp< + TRouter extends AnyRouter = AnyRouter, +> = (container: HTMLDivElement) => MountedBeforeLoadContextApp + +const taskRoute = '/app/$orgId/projects/$projectId/tasks/$taskId' + +export function createBeforeLoadContextWorkload( + framework: Framework, + mountTestApp: BeforeLoadContextMountTestApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + let mounted: MountedBeforeLoadContextApp | undefined = undefined + let currentTarget = initialTaskTarget + + const lifecycle = createClientNavLifecycle({ + mountTestApp(container) { + mounted = mountTestApp(container) + return mounted + }, + }) + + function getMounted() { + if (!mounted) { + throw new Error('before-load-context app is not mounted') + } + + return mounted + } + + function getTaskMarker() { + return lifecycle + .getContainer() + .querySelector('[data-bench-task="detail"]') + } + + function markerMatches(target: TaskRouteTarget, contextVersion?: number) { + const marker = getTaskMarker() + + if (!marker) { + return false + } + + const expectedVersion = + contextVersion === undefined ? undefined : `${contextVersion}` + + return ( + marker.dataset.orgId === target.orgId && + marker.dataset.projectId === target.projectId && + marker.dataset.taskId === target.taskId && + marker.dataset.taskChain === makeTaskChain(target) && + (expectedVersion === undefined || + marker.dataset.contextVersion === expectedVersion) + ) + } + + function assertTaskMarker(target: TaskRouteTarget, contextVersion?: number) { + if (!markerMatches(target, contextVersion)) { + const marker = getTaskMarker() + const actual = marker + ? `${marker.dataset.orgId}/${marker.dataset.projectId}/${marker.dataset.taskId}/${marker.dataset.contextVersion}` + : 'missing marker' + const expectedVersion = + contextVersion === undefined ? '*' : `${contextVersion}` + + throw new Error( + `Expected task marker ${makeTaskChain(target)}/${expectedVersion}, got ${actual}`, + ) + } + } + + async function waitForTaskMarker( + target: TaskRouteTarget, + contextVersion?: number, + ) { + await lifecycle.waitForCounter( + () => (markerMatches(target, contextVersion) ? 1 : 0), + 1, + { + label: `task marker ${makeTaskChain(target)}`, + }, + ) + } + + async function navigateToTarget(target: TaskRouteTarget) { + await lifecycle.navigate({ + to: taskRoute, + params: target, + replace: true, + }) + currentTarget = target + } + + async function invalidateRootContext(rootSeed: number) { + const controls = getMounted() + const beforeCounters = cloneBeforeLoadCounters( + controls.getBeforeLoadCounters(), + ) + const expectedVersion = controls.setRootSeed(rootSeed) + + await lifecycle.waitForPromise( + lifecycle.getRouter().invalidate({ sync: true }), + { + label: 'router.invalidate({ sync: true })', + }, + ) + assertBeforeLoadLevelsAdvanced( + beforeCounters, + controls.getBeforeLoadCounters(), + 'router.invalidate()', + ) + await waitForTaskMarker(currentTarget, expectedVersion) + } + + async function runAction() { + for (const action of beforeLoadContextActions) { + if (action.type === 'navigate') { + await navigateToTarget(action.target) + } else { + await invalidateRootContext(action.rootSeed) + } + } + } + + async function before() { + mounted = undefined + currentTarget = initialTaskTarget + + await lifecycle.before() + await waitForTaskMarker(initialTaskTarget, getMounted().getRootVersion()) + } + + async function after() { + try { + await lifecycle.after() + } finally { + mounted = undefined + currentTarget = initialTaskTarget + } + } + + async function sanity() { + await before() + + try { + const controls = getMounted() + assertAllBeforeLoadLevels( + controls.getBeforeLoadCounters(), + 'initial load', + ) + + const firstNavigation = beforeLoadContextActions.find( + (action) => action.type === 'navigate', + ) + + if (!firstNavigation || firstNavigation.type !== 'navigate') { + throw new Error('before-load-context has no navigation sanity action') + } + + await navigateToTarget(firstNavigation.target) + assertTaskMarker(firstNavigation.target, controls.getRootVersion()) + + const beforeCounters = cloneBeforeLoadCounters( + controls.getBeforeLoadCounters(), + ) + const expectedVersion = controls.setRootSeed(0x5a17a1) + + await lifecycle.waitForPromise( + lifecycle.getRouter().invalidate({ sync: true }), + { + label: 'sanity router.invalidate({ sync: true })', + }, + ) + assertBeforeLoadLevelsAdvanced( + beforeCounters, + controls.getBeforeLoadCounters(), + 'sanity invalidate', + ) + await waitForTaskMarker(firstNavigation.target, expectedVersion) + assertTaskMarker(firstNavigation.target, expectedVersion) + } finally { + await after() + } + } + + return { + name: `client before-load context loop (${framework})`, + before, + run: runAction, + sanity, + after, + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/flame-jsdom.ts b/benchmarks/client-nav/scenarios/control-flow/flame-jsdom.ts new file mode 100644 index 0000000000..1dd2c97e3e --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/flame-jsdom.ts @@ -0,0 +1,27 @@ +import { window } from '../../jsdom.ts' + +export function installControlFlowFlameGlobals() { + const hadScrollTo = 'scrollTo' in globalThis + const previousScrollTo = globalThis.scrollTo + + Object.defineProperty(globalThis, 'scrollTo', { + configurable: true, + value: window.scrollTo.bind(window), + writable: true, + }) + + return () => { + if (hadScrollTo) { + Object.defineProperty(globalThis, 'scrollTo', { + configurable: true, + value: previousScrollTo, + writable: true, + }) + return + } + + Reflect.deleteProperty(globalThis, 'scrollTo') + } +} + +export { window } diff --git a/benchmarks/client-nav/scenarios/control-flow/react/project.json b/benchmarks/client-nav/scenarios/control-flow/react/project.json new file mode 100644 index 0000000000..f22f368381 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-control-flow-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/setup.ts b/benchmarks/client-nav/scenarios/control-flow/react/setup.ts new file mode 100644 index 0000000000..ecf6eb8c64 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createControlFlowWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createControlFlowWorkload('react', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/control-flow/react/speed.bench.ts b/benchmarks/client-nav/scenarios/control-flow/react/speed.bench.ts new file mode 100644 index 0000000000..4f8fc6de9b --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav control-flow', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/react/speed.flame.ts b/benchmarks/client-nav/scenarios/control-flow/react/speed.flame.ts new file mode 100644 index 0000000000..cd38f83e06 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/speed.flame.ts @@ -0,0 +1,19 @@ +import { installControlFlowFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installControlFlowFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/app.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/app.tsx new file mode 100644 index 0000000000..538a2c129e --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/app.tsx @@ -0,0 +1,23 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/control-flow.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/control-flow.tsx new file mode 100644 index 0000000000..2c3f893579 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/control-flow.tsx @@ -0,0 +1,26 @@ +import type { ControlFlowBranch } from '../../shared' + +export { + parseControlFlowParams as parseFlowParams, + stringifyControlFlowParams as stringifyFlowParams, +} from '../../shared' + +type MarkerProps = { + branch: ControlFlowBranch + value: string + checksum?: number +} + +export function ControlFlowMarker(props: MarkerProps) { + return ( +
+ ) +} + +export function EmptyPage() { + return null +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/router.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/router.tsx new file mode 100644 index 0000000000..d28c44d10c --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/router.tsx @@ -0,0 +1,39 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { INITIAL_CONTROL_FLOW_PATH } from '../../shared' +import { errorRoute } from './routes/error' +import { fallbackRoute } from './routes/fallback' +import { notFoundRoute } from './routes/not-found' +import { redirectBeforeLoadRoute } from './routes/redirect-before-load' +import { redirectLoaderRoute } from './routes/redirect-loader' +import { rootRoute } from './routes/__root' +import { searchRoute } from './routes/search' +import { startRoute } from './routes/start' +import { targetRoute } from './routes/target' + +const routeTree = rootRoute.addChildren([ + startRoute, + targetRoute, + redirectBeforeLoadRoute, + redirectLoaderRoute, + notFoundRoute, + errorRoute, + searchRoute, + fallbackRoute, +]) + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [INITIAL_CONTROL_FLOW_PATH], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/__root.tsx new file mode 100644 index 0000000000..06101820ab --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/__root.tsx @@ -0,0 +1,21 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' +import { ROOT_ERROR_MARKER, UNMATCHED_MARKER } from '../../../shared' +import { ControlFlowMarker } from '../control-flow' + +export const rootRoute = createRootRoute({ + component: Root, + notFoundComponent: RootNotFoundComponent, + errorComponent: RootErrorComponent, +}) + +function Root() { + return +} + +function RootNotFoundComponent() { + return +} + +function RootErrorComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/error.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/error.tsx new file mode 100644 index 0000000000..a431c0f39d --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/error.tsx @@ -0,0 +1,31 @@ +import { createRoute } from '@tanstack/react-router' +import { + CONTROL_FLOW_PATHS, + ERROR_MARKER, + createShallowControlFlowError, +} from '../../../shared' +import { + ControlFlowMarker, + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const errorRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.error, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: ({ params }) => { + throw createShallowControlFlowError('loader', params.id) + }, + errorComponent: ErrorPage, + component: EmptyPage, +}) + +function ErrorPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/fallback.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/fallback.tsx new file mode 100644 index 0000000000..debe0af741 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/fallback.tsx @@ -0,0 +1,14 @@ +import { createRoute } from '@tanstack/react-router' +import { CONTROL_FLOW_PATHS, UNMATCHED_MARKER } from '../../../shared' +import { ControlFlowMarker } from '../control-flow' +import { rootRoute } from './__root' + +export const fallbackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.fallback, + component: FallbackPage, +}) + +function FallbackPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/not-found.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/not-found.tsx new file mode 100644 index 0000000000..6417a61301 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/not-found.tsx @@ -0,0 +1,27 @@ +import { createRoute, notFound } from '@tanstack/react-router' +import { CONTROL_FLOW_PATHS, NOT_FOUND_MARKER } from '../../../shared' +import { + ControlFlowMarker, + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const notFoundRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.notFound, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: () => { + throw notFound() + }, + notFoundComponent: NotFoundPage, + component: EmptyPage, +}) + +function NotFoundPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/redirect-before-load.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/redirect-before-load.tsx new file mode 100644 index 0000000000..ee3bb5c7c2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/redirect-before-load.tsx @@ -0,0 +1,24 @@ +import { createRoute, redirect } from '@tanstack/react-router' +import { + CONTROL_FLOW_PATHS, + createControlFlowTargetRedirect, +} from '../../../shared' +import { + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const redirectBeforeLoadRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.redirectBeforeLoad, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + beforeLoad: ({ params }) => { + throw redirect(createControlFlowTargetRedirect(params.id)) + }, + component: EmptyPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/redirect-loader.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/redirect-loader.tsx new file mode 100644 index 0000000000..004e5a8100 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/redirect-loader.tsx @@ -0,0 +1,24 @@ +import { createRoute, redirect } from '@tanstack/react-router' +import { + CONTROL_FLOW_PATHS, + createControlFlowTargetRedirect, +} from '../../../shared' +import { + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const redirectLoaderRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.redirectLoader, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: ({ params }) => { + throw redirect(createControlFlowTargetRedirect(params.id)) + }, + component: EmptyPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/search.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/search.tsx new file mode 100644 index 0000000000..816865554c --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/search.tsx @@ -0,0 +1,33 @@ +import { createRoute } from '@tanstack/react-router' +import { + CONTROL_FLOW_PATHS, + SEARCH_ERROR_MARKER, + validateControlFlowSearch, +} from '../../../shared' +import type { ControlFlowSearch } from '../../../shared' +import { ControlFlowMarker } from '../control-flow' +import { rootRoute } from './__root' + +export const searchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.search, + validateSearch: validateControlFlowSearch, + errorComponent: SearchErrorPage, + component: SearchPage, +}) + +function SearchErrorPage() { + return +} + +function SearchPage() { + const search = searchRoute.useSearch() as ControlFlowSearch + + return ( + + ) +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/start.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/start.tsx new file mode 100644 index 0000000000..9910c41c49 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/start.tsx @@ -0,0 +1,86 @@ +import { Link, createRoute } from '@tanstack/react-router' +import { + CONTROL_FLOW_INVALID_SEARCH_HREF, + CONTROL_FLOW_PATHS, + CONTROL_FLOW_UNMATCHED_HREF, + START_MARKER, +} from '../../../shared' +import { rootRoute } from './__root' + +export const startRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.start, + component: StartPage, +}) + +function StartPage() { + return ( +
+ +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/target.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/target.tsx new file mode 100644 index 0000000000..1e71edf2e0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/target.tsx @@ -0,0 +1,24 @@ +import { createRoute } from '@tanstack/react-router' +import { CONTROL_FLOW_PATHS } from '../../../shared' +import { + ControlFlowMarker, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const targetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.target, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + component: TargetPage, +}) + +function TargetPage() { + const params = targetRoute.useParams() + + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/tsconfig.json b/benchmarks/client-nav/scenarios/control-flow/react/tsconfig.json new file mode 100644 index 0000000000..f2e7c1160a --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/control-flow/react/vite.config.ts b/benchmarks/client-nav/scenarios/control-flow/react/vite.config.ts new file mode 100644 index 0000000000..83c8e2d919 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/react/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav control-flow (react)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/shared.ts b/benchmarks/client-nav/scenarios/control-flow/shared.ts new file mode 100644 index 0000000000..68e36c7459 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/shared.ts @@ -0,0 +1,239 @@ +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' + +export const CONTROL_FLOW_PATHS = { + start: '/flow/start', + target: '/flow/target/$id', + redirectBeforeLoad: '/flow/redirect-before-load/$id', + redirectLoader: '/flow/redirect-loader/$id', + notFound: '/flow/not-found/$id', + error: '/flow/error/$id', + search: '/flow/search', + fallback: '/flow/$', +} as const + +export const CONTROL_FLOW_INVALID_SEARCH_HREF = `${CONTROL_FLOW_PATHS.search}?mode=invalid&token=link-invalid` +export const CONTROL_FLOW_UNMATCHED_HREF = '/flow/unmatched/link' + +export const INITIAL_CONTROL_FLOW_PATH = CONTROL_FLOW_PATHS.start +export const CONTROL_FLOW_CYCLE_COUNT = 2 +export const CONTROL_FLOW_NAVIGATION_COUNT = CONTROL_FLOW_CYCLE_COUNT * 8 + +export type ControlFlowBranch = + | 'start' + | 'target' + | 'not-found' + | 'error' + | 'search-valid' + | 'search-error' + | 'unmatched' + +export interface ControlFlowMarker { + branch: ControlFlowBranch + value: string +} + +export interface ControlFlowAction { + label: string + to: string + params?: Record + search?: Record + expected: ControlFlowMarker +} + +export interface ControlFlowSearch { + mode: 'valid' + token: string + checksum: number +} + +export type ControlFlowParams = { + id: string +} + +const CONTROL_FLOW_SEED = 0xc0ff_ee10 +const EMPTY_VALUE = 'empty' + +export const START_MARKER = createControlFlowMarker('start', 'start') +export const NOT_FOUND_MARKER = createControlFlowMarker('not-found', 'route') +export const ERROR_MARKER = createControlFlowMarker('error', 'loader') +export const ROOT_ERROR_MARKER = createControlFlowMarker('error', 'root') +export const SEARCH_ERROR_MARKER = createControlFlowMarker( + 'search-error', + 'validation', +) +export const UNMATCHED_MARKER = createControlFlowMarker('unmatched', 'root') + +export function createControlFlowMarker( + branch: ControlFlowBranch, + value: string, +): ControlFlowMarker { + return { branch, value } +} + +export function normalizeFlowId(value: unknown) { + const normalized = String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + + return normalized || EMPTY_VALUE +} + +export function parseControlFlowParams(params: ControlFlowParams) { + return { + id: normalizeFlowId(params.id), + } +} + +export function stringifyControlFlowParams(params: ControlFlowParams) { + return { + id: normalizeFlowId(params.id), + } +} + +export function computeControlFlowChecksum(value: string) { + let checksum = 0 + + for (let index = 0; index < value.length; index++) { + checksum = (checksum * 33 + value.charCodeAt(index) + index) >>> 0 + } + + return checksum +} + +export function createShallowControlFlowError(kind: string, id: unknown) { + const error = new Error(`control-flow-${kind}:${normalizeFlowId(id)}`) + error.stack = '' + return error +} + +export function validateControlFlowSearch( + search: Record, +): ControlFlowSearch { + const mode = typeof search.mode === 'string' ? search.mode : 'valid' + const token = normalizeFlowId(search.token) + + if (mode === 'invalid') { + throw createShallowControlFlowError('search-validation', token) + } + + return { + mode: 'valid', + token, + checksum: computeControlFlowChecksum(token), + } +} + +export function createControlFlowTargetRedirect(id: string) { + return { + to: CONTROL_FLOW_PATHS.target, + params: { id }, + replace: true, + } +} + +function createActionId( + runIndex: number, + cycle: number, + branch: string, + random: () => number, +) { + return `${branch}-${runIndex.toString(36)}-${cycle.toString( + 36, + )}-${randomSegment(random)}` +} + +function createTargetAction(label: string, id: string): ControlFlowAction { + return { + label, + to: CONTROL_FLOW_PATHS.target, + params: { id }, + expected: createControlFlowMarker('target', normalizeFlowId(id)), + } +} + +function createRedirectAction( + label: string, + to: string, + id: string, +): ControlFlowAction { + return { + label, + to, + params: { id }, + expected: createControlFlowMarker('target', normalizeFlowId(id)), + } +} + +export function createControlFlowActions(runIndex: number) { + const random = createDeterministicRandom(CONTROL_FLOW_SEED + runIndex * 101) + const actions: Array = [] + + for (let cycle = 0; cycle < CONTROL_FLOW_CYCLE_COUNT; cycle++) { + const targetId = createActionId(runIndex, cycle, 'target', random) + const beforeLoadId = createActionId(runIndex, cycle, 'before', random) + const loaderRedirectId = createActionId(runIndex, cycle, 'loader', random) + const notFoundId = createActionId(runIndex, cycle, 'missing', random) + const errorId = createActionId(runIndex, cycle, 'error', random) + const validToken = createActionId(runIndex, cycle, 'valid-search', random) + const invalidToken = createActionId( + runIndex, + cycle, + 'invalid-search', + random, + ) + const unmatchedId = createActionId(runIndex, cycle, 'unmatched', random) + + actions.push( + createTargetAction('normal target', targetId), + createRedirectAction( + 'beforeLoad redirect', + CONTROL_FLOW_PATHS.redirectBeforeLoad, + beforeLoadId, + ), + createRedirectAction( + 'loader redirect', + CONTROL_FLOW_PATHS.redirectLoader, + loaderRedirectId, + ), + { + label: 'not found', + to: CONTROL_FLOW_PATHS.notFound, + params: { id: notFoundId }, + expected: NOT_FOUND_MARKER, + }, + { + label: 'loader error', + to: CONTROL_FLOW_PATHS.error, + params: { id: errorId }, + expected: ERROR_MARKER, + }, + { + label: 'valid search', + to: CONTROL_FLOW_PATHS.search, + search: { mode: 'valid', token: validToken, junk: `strip-${cycle}` }, + expected: createControlFlowMarker( + 'search-valid', + normalizeFlowId(validToken), + ), + }, + { + label: 'invalid search', + to: CONTROL_FLOW_PATHS.search, + search: { mode: 'invalid', token: invalidToken }, + expected: SEARCH_ERROR_MARKER, + }, + { + label: 'unmatched', + to: `/flow/unmatched/${unmatchedId}`, + expected: UNMATCHED_MARKER, + }, + ) + } + + return actions +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/project.json b/benchmarks/client-nav/scenarios/control-flow/solid/project.json new file mode 100644 index 0000000000..9ca98ee022 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-control-flow-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/setup.ts b/benchmarks/client-nav/scenarios/control-flow/solid/setup.ts new file mode 100644 index 0000000000..4fb02f82fd --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createControlFlowWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createControlFlowWorkload('solid', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/control-flow/solid/speed.bench.ts new file mode 100644 index 0000000000..4f8fc6de9b --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav control-flow', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/control-flow/solid/speed.flame.ts new file mode 100644 index 0000000000..cd38f83e06 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/speed.flame.ts @@ -0,0 +1,19 @@ +import { installControlFlowFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installControlFlowFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/app.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/app.tsx new file mode 100644 index 0000000000..7c5f5713f4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/app.tsx @@ -0,0 +1,21 @@ +import { RouterProvider } from '@tanstack/solid-router' +import { render } from 'solid-js/web' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/control-flow.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/control-flow.tsx new file mode 100644 index 0000000000..2c3f893579 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/control-flow.tsx @@ -0,0 +1,26 @@ +import type { ControlFlowBranch } from '../../shared' + +export { + parseControlFlowParams as parseFlowParams, + stringifyControlFlowParams as stringifyFlowParams, +} from '../../shared' + +type MarkerProps = { + branch: ControlFlowBranch + value: string + checksum?: number +} + +export function ControlFlowMarker(props: MarkerProps) { + return ( +
+ ) +} + +export function EmptyPage() { + return null +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/router.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/router.tsx new file mode 100644 index 0000000000..49ec08059d --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/router.tsx @@ -0,0 +1,39 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { INITIAL_CONTROL_FLOW_PATH } from '../../shared' +import { errorRoute } from './routes/error' +import { fallbackRoute } from './routes/fallback' +import { notFoundRoute } from './routes/not-found' +import { redirectBeforeLoadRoute } from './routes/redirect-before-load' +import { redirectLoaderRoute } from './routes/redirect-loader' +import { rootRoute } from './routes/__root' +import { searchRoute } from './routes/search' +import { startRoute } from './routes/start' +import { targetRoute } from './routes/target' + +const routeTree = rootRoute.addChildren([ + startRoute, + targetRoute, + redirectBeforeLoadRoute, + redirectLoaderRoute, + notFoundRoute, + errorRoute, + searchRoute, + fallbackRoute, +]) + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [INITIAL_CONTROL_FLOW_PATH], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..afac581cb7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/__root.tsx @@ -0,0 +1,21 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' +import { ROOT_ERROR_MARKER, UNMATCHED_MARKER } from '../../../shared' +import { ControlFlowMarker } from '../control-flow' + +export const rootRoute = createRootRoute({ + component: Root, + notFoundComponent: RootNotFoundComponent, + errorComponent: RootErrorComponent, +}) + +function Root() { + return +} + +function RootNotFoundComponent() { + return +} + +function RootErrorComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/error.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/error.tsx new file mode 100644 index 0000000000..f8d97d63c1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/error.tsx @@ -0,0 +1,31 @@ +import { createRoute } from '@tanstack/solid-router' +import { + CONTROL_FLOW_PATHS, + ERROR_MARKER, + createShallowControlFlowError, +} from '../../../shared' +import { + ControlFlowMarker, + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const errorRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.error, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: ({ params }) => { + throw createShallowControlFlowError('loader', params.id) + }, + errorComponent: ErrorPage, + component: EmptyPage, +}) + +function ErrorPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/fallback.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/fallback.tsx new file mode 100644 index 0000000000..090183fdb1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/fallback.tsx @@ -0,0 +1,14 @@ +import { createRoute } from '@tanstack/solid-router' +import { CONTROL_FLOW_PATHS, UNMATCHED_MARKER } from '../../../shared' +import { ControlFlowMarker } from '../control-flow' +import { rootRoute } from './__root' + +export const fallbackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.fallback, + component: FallbackPage, +}) + +function FallbackPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/not-found.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/not-found.tsx new file mode 100644 index 0000000000..ff614ad4fb --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/not-found.tsx @@ -0,0 +1,27 @@ +import { createRoute, notFound } from '@tanstack/solid-router' +import { CONTROL_FLOW_PATHS, NOT_FOUND_MARKER } from '../../../shared' +import { + ControlFlowMarker, + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const notFoundRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.notFound, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: () => { + throw notFound() + }, + notFoundComponent: NotFoundPage, + component: EmptyPage, +}) + +function NotFoundPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/redirect-before-load.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/redirect-before-load.tsx new file mode 100644 index 0000000000..66f985790b --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/redirect-before-load.tsx @@ -0,0 +1,24 @@ +import { createRoute, redirect } from '@tanstack/solid-router' +import { + CONTROL_FLOW_PATHS, + createControlFlowTargetRedirect, +} from '../../../shared' +import { + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const redirectBeforeLoadRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.redirectBeforeLoad, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + beforeLoad: ({ params }) => { + throw redirect(createControlFlowTargetRedirect(params.id)) + }, + component: EmptyPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/redirect-loader.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/redirect-loader.tsx new file mode 100644 index 0000000000..6ea13790b3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/redirect-loader.tsx @@ -0,0 +1,24 @@ +import { createRoute, redirect } from '@tanstack/solid-router' +import { + CONTROL_FLOW_PATHS, + createControlFlowTargetRedirect, +} from '../../../shared' +import { + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const redirectLoaderRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.redirectLoader, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: ({ params }) => { + throw redirect(createControlFlowTargetRedirect(params.id)) + }, + component: EmptyPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/search.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/search.tsx new file mode 100644 index 0000000000..e12a17354a --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/search.tsx @@ -0,0 +1,33 @@ +import { createRoute } from '@tanstack/solid-router' +import { + CONTROL_FLOW_PATHS, + SEARCH_ERROR_MARKER, + validateControlFlowSearch, +} from '../../../shared' +import type { ControlFlowSearch } from '../../../shared' +import { ControlFlowMarker } from '../control-flow' +import { rootRoute } from './__root' + +export const searchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.search, + validateSearch: validateControlFlowSearch, + errorComponent: SearchErrorPage, + component: SearchPage, +}) + +function SearchErrorPage() { + return +} + +function SearchPage() { + const search = searchRoute.useSearch() as () => ControlFlowSearch + + return ( + + ) +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/start.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/start.tsx new file mode 100644 index 0000000000..b04ea717c9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/start.tsx @@ -0,0 +1,86 @@ +import { Link, createRoute } from '@tanstack/solid-router' +import { + CONTROL_FLOW_INVALID_SEARCH_HREF, + CONTROL_FLOW_PATHS, + CONTROL_FLOW_UNMATCHED_HREF, + START_MARKER, +} from '../../../shared' +import { rootRoute } from './__root' + +export const startRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.start, + component: StartPage, +}) + +function StartPage() { + return ( +
+ +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/target.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/target.tsx new file mode 100644 index 0000000000..6e624f9e75 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/target.tsx @@ -0,0 +1,24 @@ +import { createRoute } from '@tanstack/solid-router' +import { CONTROL_FLOW_PATHS } from '../../../shared' +import { + ControlFlowMarker, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const targetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.target, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + component: TargetPage, +}) + +function TargetPage() { + const params = targetRoute.useParams() + + return +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/tsconfig.json b/benchmarks/client-nav/scenarios/control-flow/solid/tsconfig.json new file mode 100644 index 0000000000..8a2c232fdb --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/vite.config.ts b/benchmarks/client-nav/scenarios/control-flow/solid/vite.config.ts new file mode 100644 index 0000000000..f9472b789b --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/solid/vite.config.ts @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav control-flow (solid)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/project.json b/benchmarks/client-nav/scenarios/control-flow/vue/project.json new file mode 100644 index 0000000000..dc991123e0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-control-flow-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/setup.ts b/benchmarks/client-nav/scenarios/control-flow/vue/setup.ts new file mode 100644 index 0000000000..84672ac0f8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createControlFlowWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createControlFlowWorkload('vue', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/control-flow/vue/speed.bench.ts new file mode 100644 index 0000000000..4f8fc6de9b --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav control-flow', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/control-flow/vue/speed.flame.ts new file mode 100644 index 0000000000..cd38f83e06 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/speed.flame.ts @@ -0,0 +1,19 @@ +import { installControlFlowFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installControlFlowFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/app.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/app.tsx new file mode 100644 index 0000000000..49991f1d3a --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/app.tsx @@ -0,0 +1,25 @@ +import * as Vue from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = Vue.createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/control-flow.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/control-flow.tsx new file mode 100644 index 0000000000..5465056ea3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/control-flow.tsx @@ -0,0 +1,29 @@ +import * as Vue from 'vue' +import type { ControlFlowBranch } from '../../shared' + +export { + parseControlFlowParams as parseFlowParams, + stringifyControlFlowParams as stringifyFlowParams, +} from '../../shared' + +type MarkerProps = { + branch: ControlFlowBranch + value: string + checksum?: number +} + +export function createControlFlowMarkerElement(props: MarkerProps) { + return ( +
+ ) +} + +export const EmptyPage = Vue.defineComponent({ + setup() { + return () => null + }, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/router.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/router.tsx new file mode 100644 index 0000000000..f5e017cd2e --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/router.tsx @@ -0,0 +1,39 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { INITIAL_CONTROL_FLOW_PATH } from '../../shared' +import { errorRoute } from './routes/error' +import { fallbackRoute } from './routes/fallback' +import { notFoundRoute } from './routes/not-found' +import { redirectBeforeLoadRoute } from './routes/redirect-before-load' +import { redirectLoaderRoute } from './routes/redirect-loader' +import { rootRoute } from './routes/__root' +import { searchRoute } from './routes/search' +import { startRoute } from './routes/start' +import { targetRoute } from './routes/target' + +const routeTree = rootRoute.addChildren([ + startRoute, + targetRoute, + redirectBeforeLoadRoute, + redirectLoaderRoute, + notFoundRoute, + errorRoute, + searchRoute, + fallbackRoute, +]) + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [INITIAL_CONTROL_FLOW_PATH], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..ea4b800cdd --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/__root.tsx @@ -0,0 +1,30 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' +import { ROOT_ERROR_MARKER, UNMATCHED_MARKER } from '../../../shared' +import { createControlFlowMarkerElement } from '../control-flow' + +const Root: ReturnType = Vue.defineComponent({ + setup() { + return () => + }, +}) + +const RootNotFoundComponent: ReturnType = + Vue.defineComponent({ + setup() { + return () => createControlFlowMarkerElement(UNMATCHED_MARKER) + }, + }) + +const RootErrorComponent: ReturnType = + Vue.defineComponent({ + setup() { + return () => createControlFlowMarkerElement(ROOT_ERROR_MARKER) + }, + }) + +export const rootRoute = createRootRoute({ + component: Root, + notFoundComponent: RootNotFoundComponent, + errorComponent: RootErrorComponent, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/error.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/error.tsx new file mode 100644 index 0000000000..d5f257dc1a --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/error.tsx @@ -0,0 +1,34 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + CONTROL_FLOW_PATHS, + ERROR_MARKER, + createShallowControlFlowError, +} from '../../../shared' +import { + EmptyPage, + createControlFlowMarkerElement, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +const ErrorPage: ReturnType = Vue.defineComponent({ + setup() { + return () => createControlFlowMarkerElement(ERROR_MARKER) + }, +}) + +export const errorRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.error, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: ({ params }) => { + throw createShallowControlFlowError('loader', params.id) + }, + errorComponent: ErrorPage, + component: EmptyPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/fallback.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/fallback.tsx new file mode 100644 index 0000000000..765e9cab7d --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/fallback.tsx @@ -0,0 +1,18 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { CONTROL_FLOW_PATHS, UNMATCHED_MARKER } from '../../../shared' +import { createControlFlowMarkerElement } from '../control-flow' +import { rootRoute } from './__root' + +const FallbackPage: ReturnType = + Vue.defineComponent({ + setup() { + return () => createControlFlowMarkerElement(UNMATCHED_MARKER) + }, + }) + +export const fallbackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.fallback, + component: FallbackPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/not-found.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/not-found.tsx new file mode 100644 index 0000000000..4d6eef5a13 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/not-found.tsx @@ -0,0 +1,31 @@ +import * as Vue from 'vue' +import { createRoute, notFound } from '@tanstack/vue-router' +import { CONTROL_FLOW_PATHS, NOT_FOUND_MARKER } from '../../../shared' +import { + EmptyPage, + createControlFlowMarkerElement, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +const NotFoundPage: ReturnType = + Vue.defineComponent({ + setup() { + return () => createControlFlowMarkerElement(NOT_FOUND_MARKER) + }, + }) + +export const notFoundRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.notFound, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: () => { + throw notFound() + }, + notFoundComponent: NotFoundPage, + component: EmptyPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/redirect-before-load.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/redirect-before-load.tsx new file mode 100644 index 0000000000..f0f9ab6c16 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/redirect-before-load.tsx @@ -0,0 +1,24 @@ +import { createRoute, redirect } from '@tanstack/vue-router' +import { + CONTROL_FLOW_PATHS, + createControlFlowTargetRedirect, +} from '../../../shared' +import { + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const redirectBeforeLoadRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.redirectBeforeLoad, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + beforeLoad: ({ params }) => { + throw redirect(createControlFlowTargetRedirect(params.id)) + }, + component: EmptyPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/redirect-loader.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/redirect-loader.tsx new file mode 100644 index 0000000000..04c2d3e9df --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/redirect-loader.tsx @@ -0,0 +1,24 @@ +import { createRoute, redirect } from '@tanstack/vue-router' +import { + CONTROL_FLOW_PATHS, + createControlFlowTargetRedirect, +} from '../../../shared' +import { + EmptyPage, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +export const redirectLoaderRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.redirectLoader, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + loader: ({ params }) => { + throw redirect(createControlFlowTargetRedirect(params.id)) + }, + component: EmptyPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/search.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/search.tsx new file mode 100644 index 0000000000..a04c2be095 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/search.tsx @@ -0,0 +1,38 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + CONTROL_FLOW_PATHS, + SEARCH_ERROR_MARKER, + validateControlFlowSearch, +} from '../../../shared' +import type { ControlFlowSearch } from '../../../shared' +import { createControlFlowMarkerElement } from '../control-flow' +import { rootRoute } from './__root' + +const SearchErrorPage: ReturnType = + Vue.defineComponent({ + setup() { + return () => createControlFlowMarkerElement(SEARCH_ERROR_MARKER) + }, + }) + +const SearchPage: ReturnType = Vue.defineComponent({ + setup() { + const search = searchRoute.useSearch() as Vue.Ref + + return () => + createControlFlowMarkerElement({ + branch: 'search-valid', + value: search.value.token, + checksum: search.value.checksum, + }) + }, +}) + +export const searchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.search, + validateSearch: validateControlFlowSearch, + errorComponent: SearchErrorPage, + component: SearchPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/start.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/start.tsx new file mode 100644 index 0000000000..8902cb8c0a --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/start.tsx @@ -0,0 +1,89 @@ +import * as Vue from 'vue' +import { Link, createRoute } from '@tanstack/vue-router' +import { + CONTROL_FLOW_INVALID_SEARCH_HREF, + CONTROL_FLOW_PATHS, + CONTROL_FLOW_UNMATCHED_HREF, + START_MARKER, +} from '../../../shared' +import { rootRoute } from './__root' + +const StartPage: ReturnType = Vue.defineComponent({ + setup() { + return () => ( +
+ +
+ ) + }, +}) + +export const startRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.start, + component: StartPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/target.tsx b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/target.tsx new file mode 100644 index 0000000000..51d4f7d04b --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/src/routes/target.tsx @@ -0,0 +1,31 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { CONTROL_FLOW_PATHS } from '../../../shared' +import { + createControlFlowMarkerElement, + parseFlowParams, + stringifyFlowParams, +} from '../control-flow' +import { rootRoute } from './__root' + +const TargetPage: ReturnType = Vue.defineComponent({ + setup() { + const params = targetRoute.useParams() + + return () => + createControlFlowMarkerElement({ + branch: 'target', + value: params.value.id, + }) + }, +}) + +export const targetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: CONTROL_FLOW_PATHS.target, + params: { + parse: parseFlowParams, + stringify: stringifyFlowParams, + }, + component: TargetPage, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/tsconfig.json b/benchmarks/client-nav/scenarios/control-flow/vue/tsconfig.json new file mode 100644 index 0000000000..a83100c64e --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/control-flow/vue/vite.config.ts b/benchmarks/client-nav/scenarios/control-flow/vue/vite.config.ts new file mode 100644 index 0000000000..c7392783d8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/vue/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav control-flow (vue)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/control-flow/workload.ts b/benchmarks/client-nav/scenarios/control-flow/workload.ts new file mode 100644 index 0000000000..9bf4f17a63 --- /dev/null +++ b/benchmarks/client-nav/scenarios/control-flow/workload.ts @@ -0,0 +1,150 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import { + START_MARKER, + createControlFlowActions, + type ControlFlowAction, + type ControlFlowMarker, +} from './shared' + +function readControlFlowMarker(container: ParentNode) { + const element = container.querySelector( + '[data-control-flow-branch]', + ) + + if (!element) { + return undefined + } + + return { + branch: element.dataset.controlFlowBranch, + value: element.dataset.controlFlowValue, + } +} + +function formatControlFlowMarker(marker: ControlFlowMarker) { + return `${marker.branch}/${marker.value}` +} + +function isExpectedControlFlowError(value: unknown) { + const message = + value instanceof Error + ? `${value.name}:${value.message}:${String(value.cause)}` + : String(value) + + return ( + message.includes('control-flow-loader:') || + message.includes('control-flow-search-validation:') + ) +} + +function installControlFlowConsoleFilter() { + const previousConsoleError = console.error + + console.error = (...args: Array) => { + if (args.some(isExpectedControlFlowError)) { + return + } + + previousConsoleError(...args) + } + + return () => { + console.error = previousConsoleError + } +} + +export function createControlFlowWorkload( + framework: Framework, + mountTestApp: MountTestApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp }) + let runIndex = 0 + + function assertControlFlowMarker(expected: ControlFlowMarker) { + const actual = readControlFlowMarker(lifecycle.getContainer()) + + if (actual?.branch !== expected.branch || actual.value !== expected.value) { + throw new Error( + `Expected control-flow marker ${formatControlFlowMarker( + expected, + )}, got ${actual?.branch ?? 'missing'}/${actual?.value ?? 'missing'}`, + ) + } + } + + async function waitForControlFlowMarker(expected: ControlFlowMarker) { + await lifecycle.waitForCounter( + () => { + const actual = readControlFlowMarker(lifecycle.getContainer()) + return actual?.branch === expected.branch && + actual.value === expected.value + ? 1 + : 0 + }, + 1, + { + label: `control-flow marker ${formatControlFlowMarker(expected)}`, + }, + ) + + assertControlFlowMarker(expected) + } + + async function navigateTo(action: ControlFlowAction) { + await lifecycle.navigate( + { + to: action.to as never, + params: action.params, + search: action.search, + replace: true, + }, + { + label: `control-flow ${action.label}`, + wait: 'rendered', + }, + ) + + await waitForControlFlowMarker(action.expected) + } + + async function before() { + runIndex = 0 + await lifecycle.before() + lifecycle.addCleanup(installControlFlowConsoleFilter()) + await waitForControlFlowMarker(START_MARKER) + } + + async function run() { + const actions = createControlFlowActions(runIndex) + runIndex += 1 + + for (const action of actions) { + await navigateTo(action) + } + } + + async function sanity() { + await before() + + try { + await run() + } finally { + await lifecycle.after() + } + } + + return { + name: `client control flow loop (${framework})`, + before, + run, + sanity, + after: lifecycle.after, + } +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/project.json b/benchmarks/client-nav/scenarios/deferred-await/react/project.json new file mode 100644 index 0000000000..7d1bb5c19e --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-deferred-await-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/setup.ts b/benchmarks/client-nav/scenarios/deferred-await/react/setup.ts new file mode 100644 index 0000000000..96c1cb1838 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/setup.ts @@ -0,0 +1,18 @@ +import type * as App from './src/app' +import { createDeferredAwaitWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { + getDeferredRegistrySnapshot, + mountTestApp, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +} = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createDeferredAwaitWorkload('react', mountTestApp, { + getDeferredRegistrySnapshot, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/speed.bench.ts b/benchmarks/client-nav/scenarios/deferred-await/react/speed.bench.ts new file mode 100644 index 0000000000..f89ddfd4fa --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/speed.bench.ts @@ -0,0 +1,21 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +const deferredAwaitBenchOptions = { + ...clientNavBenchOptions, + warmupIterations: 1, +} + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...deferredAwaitBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/speed.flame.ts b/benchmarks/client-nav/scenarios/deferred-await/react/speed.flame.ts new file mode 100644 index 0000000000..df681c2185 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/speed.flame.ts @@ -0,0 +1,17 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/app.tsx b/benchmarks/client-nav/scenarios/deferred-await/react/src/app.tsx new file mode 100644 index 0000000000..bff25f60f3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/app.tsx @@ -0,0 +1,30 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} + +export { + getDeferredRegistrySnapshot, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +} from '../../shared' diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/deferred-value.tsx b/benchmarks/client-nav/scenarios/deferred-await/react/src/deferred-value.tsx new file mode 100644 index 0000000000..19ab91e782 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/deferred-value.tsx @@ -0,0 +1,31 @@ +import { Await } from '@tanstack/react-router' +import { + deferredFallbackMarker, + deferredResolvedMarker, + type DeferredPayload, +} from '../../shared' + +export function DeferredValue(props: { + markerKey: string + promise: Promise +}) { + return ( + + Loading {props.markerKey} + + } + > + {(payload) => ( + + {payload.label} + + )} + + ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/routeTree.ts b/benchmarks/client-nav/scenarios/deferred-await/react/src/routeTree.ts new file mode 100644 index 0000000000..a90990d581 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/routeTree.ts @@ -0,0 +1,11 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as deferredIndexRoute } from './routes/deferred' +import { Route as itemRoute } from './routes/deferred.items.$itemId' +import { Route as detailsRoute } from './routes/deferred.items.$itemId.details' +import { Route as reportRoute } from './routes/deferred.reports.$reportId' + +export const routeTree = rootRoute.addChildren([ + deferredIndexRoute, + itemRoute.addChildren([detailsRoute]), + reportRoute, +]) diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/router.tsx b/benchmarks/client-nav/scenarios/deferred-await/react/src/router.tsx new file mode 100644 index 0000000000..6a4e58c19d --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/router.tsx @@ -0,0 +1,20 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { deferredInitialLocation, deferredRouterPendingMs } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [deferredInitialLocation], + }), + defaultPendingMs: deferredRouterPendingMs, + defaultPendingMinMs: deferredRouterPendingMs, + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/__root.tsx new file mode 100644 index 0000000000..889395056b --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.items.$itemId.details.tsx b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.items.$itemId.details.tsx new file mode 100644 index 0000000000..8dfed8f25d --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.items.$itemId.details.tsx @@ -0,0 +1,30 @@ +import { createRoute } from '@tanstack/react-router' +import { + createDetailsLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, +} from '../../../shared' +import { DeferredValue } from '../deferred-value' +import { Route as itemRoute } from './deferred.items.$itemId' + +export const Route = createRoute({ + getParentRoute: () => itemRoute, + path: 'details', + loader: ({ params }) => createDetailsLoaderData(params.itemId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: DetailsPage, +}) + +function DetailsPage() { + const data = Route.useLoaderData() + + return ( +
+ + {data.critical.label} + + +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.items.$itemId.tsx b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.items.$itemId.tsx new file mode 100644 index 0000000000..478dababdb --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.items.$itemId.tsx @@ -0,0 +1,32 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { + createItemLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, +} from '../../../shared' +import { DeferredValue } from '../deferred-value' +import { Route as rootRoute } from './__root' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred/items/$itemId', + loader: ({ params }) => createItemLoaderData(params.itemId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: ItemPage, +}) + +function ItemPage() { + const data = Route.useLoaderData() + + return ( +
+ + {data.critical.label} + + + + +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.reports.$reportId.tsx new file mode 100644 index 0000000000..159471e2de --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.reports.$reportId.tsx @@ -0,0 +1,37 @@ +import { createRoute } from '@tanstack/react-router' +import { + createReportLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, + type ReportSectionLoaderData, +} from '../../../shared' +import { DeferredValue } from '../deferred-value' +import { Route as rootRoute } from './__root' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred/reports/$reportId', + loader: ({ params }) => createReportLoaderData(params.reportId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: ReportPage, +}) + +function ReportPage() { + const data = Route.useLoaderData() + + return ( +
+ + {data.critical.label} + + {data.sections.map((section: ReportSectionLoaderData) => ( + + ))} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.tsx b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.tsx new file mode 100644 index 0000000000..3e583324fe --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/src/routes/deferred.tsx @@ -0,0 +1,12 @@ +import { createRoute } from '@tanstack/react-router' +import { Route as rootRoute } from './__root' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred', + component: DeferredIndex, +}) + +function DeferredIndex() { + return
+} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/tsconfig.json b/benchmarks/client-nav/scenarios/deferred-await/react/tsconfig.json new file mode 100644 index 0000000000..2b2b43c1a3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/react/vite.config.ts b/benchmarks/client-nav/scenarios/deferred-await/react/vite.config.ts new file mode 100644 index 0000000000..21f87fc6d8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav deferred-await (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/shared.ts b/benchmarks/client-nav/scenarios/deferred-await/shared.ts new file mode 100644 index 0000000000..7514d6b0f9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/shared.ts @@ -0,0 +1,504 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' + +export interface CriticalDeferredData { + id: string + label: string + checksum: number +} + +export interface DeferredPayload { + key: string + label: string + checksum: number +} + +export interface ItemLoaderData { + critical: CriticalDeferredData + primary: Promise + primaryKey: string + secondary: Promise + secondaryKey: string +} + +export interface DetailsLoaderData { + critical: CriticalDeferredData + details: Promise + detailsKey: string +} + +export interface ReportSectionLoaderData { + key: string + promise: Promise +} + +export interface ReportLoaderData { + critical: CriticalDeferredData + sections: Array +} + +export interface DeferredRegistrySnapshot { + pendingKeys: Array +} + +export interface DeferredAwaitControls { + getDeferredRegistrySnapshot: () => DeferredRegistrySnapshot + resetDeferredRegistry: () => void + resolveDeferredKey: (key: string) => DeferredPayload + resolveReportDeferredKeys: (reportId: string) => Array +} + +type ControlledDeferred = { + key: string + payload: DeferredPayload + promise: Promise + resolve: (payload: DeferredPayload) => void +} + +type DeferredTarget = { + itemId: string + reportId: string +} + +export const REPORT_SECTION_COUNT = 6 +export const deferredInitialLocation = '/deferred' +export const deferredRouteGcTime = 0 +export const deferredRouterPendingMs = 0 + +const cycleCountPerInvocation = 8 +const staleWindowMs = 60_000 +const benchmarkRandom = createDeterministicRandom(0x64656672) + +let benchmarkSequence = 0 +let deferredRegistry = new Map() + +function normalizeSegment(value: string) { + return value.replace(/[^a-zA-Z0-9-]/g, '-') +} + +function nextTargetId(label: string) { + const sequence = benchmarkSequence.toString(36) + return `${label}-${sequence}-${randomSegment(benchmarkRandom)}` +} + +function createDeferredTarget(): DeferredTarget { + benchmarkSequence += 1 + + return { + itemId: nextTargetId('item'), + reportId: nextTargetId('report'), + } +} + +function createCriticalData(kind: string, id: string): CriticalDeferredData { + const seed = `${kind}:${id}` + + return { + id, + label: `${kind}-${id}`, + checksum: runDeferredComputation(seed, 18), + } +} + +function createDeferredPayload(key: string): DeferredPayload { + return { + key, + label: `resolved-${key}`, + checksum: runDeferredComputation(key, 28), + } +} + +function registerDeferredPayload(key: string) { + if (deferredRegistry.has(key)) { + throw new Error(`Deferred key already registered: ${key}`) + } + + const payload = createDeferredPayload(key) + let resolveDeferred!: (payload: DeferredPayload) => void + const promise = new Promise((resolve) => { + resolveDeferred = resolve + }) + + deferredRegistry.set(key, { + key, + payload, + promise, + resolve: resolveDeferred, + }) + + return promise +} + +export function deferredRouteStaleTime() { + return staleWindowMs +} + +function runDeferredComputation(seed: string | number, rounds = 24) { + const text = String(seed) + let value = typeof seed === 'number' ? Math.trunc(seed) : text.length + + for (let index = 0; index < rounds; index++) { + const charCode = text.charCodeAt(index % text.length) || 0 + value = (value * 1664525 + 1013904223 + charCode + index) >>> 0 + } + + return value +} + +export function itemPrimaryKey(itemId: string) { + return `item-primary-${normalizeSegment(itemId)}` +} + +export function itemSecondaryKey(itemId: string) { + return `item-secondary-${normalizeSegment(itemId)}` +} + +export function itemDetailsKey(itemId: string) { + return `item-details-${normalizeSegment(itemId)}` +} + +export function reportSectionKey(reportId: string, sectionIndex: number) { + return `report-section-${normalizeSegment(reportId)}-${sectionIndex}` +} + +export function deferredFallbackMarker(key: string) { + return `fallback-${key}` +} + +export function deferredResolvedMarker(key: string) { + return `resolved-${key}` +} + +export function createItemLoaderData(itemId: string): ItemLoaderData { + const primaryKey = itemPrimaryKey(itemId) + const secondaryKey = itemSecondaryKey(itemId) + + return { + critical: createCriticalData('item', itemId), + primary: registerDeferredPayload(primaryKey), + primaryKey, + secondary: registerDeferredPayload(secondaryKey), + secondaryKey, + } +} + +export function createDetailsLoaderData(itemId: string): DetailsLoaderData { + const detailsKey = itemDetailsKey(itemId) + + return { + critical: createCriticalData('details', itemId), + details: registerDeferredPayload(detailsKey), + detailsKey, + } +} + +export function createReportLoaderData(reportId: string): ReportLoaderData { + return { + critical: createCriticalData('report', reportId), + sections: Array.from( + { length: REPORT_SECTION_COUNT }, + (_, sectionIndex) => { + const key = reportSectionKey(reportId, sectionIndex) + + return { + key, + promise: registerDeferredPayload(key), + } + }, + ), + } +} + +export function getDeferredRegistrySnapshot(): DeferredRegistrySnapshot { + return { + pendingKeys: Array.from(deferredRegistry.keys()).sort(), + } +} + +export function resolveDeferredKey(key: string) { + const entry = deferredRegistry.get(key) + + if (!entry) { + throw new Error(`Missing deferred key: ${key}`) + } + + deferredRegistry.delete(key) + entry.resolve(entry.payload) + + return entry.payload +} + +export function resolveReportDeferredKeys(reportId: string) { + const payloads: Array = [] + + for (let index = 0; index < REPORT_SECTION_COUNT; index++) { + payloads.push(resolveDeferredKey(reportSectionKey(reportId, index))) + } + + return payloads +} + +export function resetDeferredRegistry() { + for (const entry of deferredRegistry.values()) { + entry.resolve(entry.payload) + } + + deferredRegistry = new Map() +} + +function selectorForMarker(marker: string) { + return `[data-deferred-marker="${marker}"]` +} + +function readPageMarker(container: ParentNode) { + const markers = Array.from( + container.querySelectorAll('[data-deferred-page]'), + ) + const marker = markers[markers.length - 1] + + return { + page: marker?.dataset.deferredPage, + id: marker?.dataset.deferredId, + } +} + +function hasMarker(container: ParentNode, marker: string) { + return container.querySelector(selectorForMarker(marker)) !== null +} + +function createReportResolvedMarkers(reportId: string) { + return Array.from({ length: REPORT_SECTION_COUNT }, (_, index) => + deferredResolvedMarker(reportSectionKey(reportId, index)), + ) +} + +function createReportFallbackMarkers(reportId: string) { + return Array.from({ length: REPORT_SECTION_COUNT }, (_, index) => + deferredFallbackMarker(reportSectionKey(reportId, index)), + ) +} + +export function createDeferredAwaitWorkload( + framework: Framework, + mountTestApp: MountTestApp, + controls: DeferredAwaitControls, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp }) + + function assertPageMarker(expectedPage: string, expectedId?: string) { + const actual = readPageMarker(lifecycle.getContainer()) + + if (actual.page !== expectedPage || actual.id !== expectedId) { + throw new Error( + `Expected deferred page ${expectedPage}/${expectedId ?? ''}, got ${actual.page ?? 'missing'}/${actual.id ?? ''}`, + ) + } + } + + function assertMarkerMissing(marker: string) { + if (hasMarker(lifecycle.getContainer(), marker)) { + throw new Error(`Expected deferred marker to be absent: ${marker}`) + } + } + + function assertPending(key: string, expected: boolean) { + const snapshot = controls.getDeferredRegistrySnapshot() + const isPending = snapshot.pendingKeys.includes(key) + + if (isPending !== expected) { + throw new Error( + `Expected deferred key ${key} pending=${expected}, got ${isPending}`, + ) + } + } + + async function waitForPage(expectedPage: string, expectedId?: string) { + await lifecycle.waitForCounter( + () => { + const actual = readPageMarker(lifecycle.getContainer()) + return actual.page === expectedPage && actual.id === expectedId ? 1 : 0 + }, + 1, + { label: `deferred page ${expectedPage}/${expectedId ?? ''}` }, + ) + + assertPageMarker(expectedPage, expectedId) + } + + async function waitForMarker(marker: string) { + await lifecycle.waitForCounter( + () => (hasMarker(lifecycle.getContainer(), marker) ? 1 : 0), + 1, + { label: `deferred marker ${marker}` }, + ) + } + + async function waitForMarkers(markers: Array, label: string) { + await lifecycle.waitForCounter( + () => + markers.reduce( + (count, marker) => + hasMarker(lifecycle.getContainer(), marker) ? count + 1 : count, + 0, + ), + markers.length, + { label }, + ) + } + + async function navigateToItem(itemId: string) { + const primaryKey = itemPrimaryKey(itemId) + const secondaryKey = itemSecondaryKey(itemId) + + await lifecycle.navigate( + { + to: '/deferred/items/$itemId', + params: { itemId }, + replace: true, + }, + { label: `deferred item ${itemId}`, wait: 'resolved' }, + ) + await waitForPage('item', itemId) + await waitForMarker(deferredFallbackMarker(primaryKey)) + await waitForMarker(deferredFallbackMarker(secondaryKey)) + assertMarkerMissing(deferredResolvedMarker(primaryKey)) + assertMarkerMissing(deferredResolvedMarker(secondaryKey)) + } + + async function navigateToDetails(itemId: string) { + const detailsKey = itemDetailsKey(itemId) + + await lifecycle.navigate( + { + to: '/deferred/items/$itemId/details', + params: { itemId }, + replace: true, + }, + { label: `deferred details ${itemId}`, wait: 'resolved' }, + ) + await waitForPage('details', itemId) + await waitForMarker(deferredFallbackMarker(detailsKey)) + assertMarkerMissing(deferredResolvedMarker(detailsKey)) + } + + async function navigateToReport(reportId: string) { + const fallbackMarkers = createReportFallbackMarkers(reportId) + + await lifecycle.navigate( + { + to: '/deferred/reports/$reportId', + params: { reportId }, + replace: true, + }, + { label: `deferred report ${reportId}`, wait: 'resolved' }, + ) + await waitForPage('report', reportId) + await waitForMarkers(fallbackMarkers, `report fallbacks ${reportId}`) + + for (const marker of createReportResolvedMarkers(reportId)) { + assertMarkerMissing(marker) + } + } + + async function resolveAndWait(key: string) { + controls.resolveDeferredKey(key) + await waitForMarker(deferredResolvedMarker(key)) + assertPending(key, false) + } + + async function resolveReportAndWait(reportId: string) { + controls.resolveReportDeferredKeys(reportId) + await waitForMarkers( + createReportResolvedMarkers(reportId), + `report resolved markers ${reportId}`, + ) + + for (let index = 0; index < REPORT_SECTION_COUNT; index++) { + assertPending(reportSectionKey(reportId, index), false) + } + } + + async function runCycle(target: DeferredTarget) { + await navigateToItem(target.itemId) + await resolveAndWait(itemPrimaryKey(target.itemId)) + await resolveAndWait(itemSecondaryKey(target.itemId)) + await navigateToDetails(target.itemId) + await resolveAndWait(itemDetailsKey(target.itemId)) + await navigateToReport(target.reportId) + await resolveReportAndWait(target.reportId) + } + + async function before() { + controls.resetDeferredRegistry() + await lifecycle.before() + await waitForPage('index') + } + + async function after() { + try { + await lifecycle.after() + } finally { + controls.resetDeferredRegistry() + } + } + + async function run() { + for (let index = 0; index < cycleCountPerInvocation; index++) { + await runCycle(createDeferredTarget()) + } + } + + async function sanity() { + await before() + + try { + const itemId = 'sanity-item' + const reportId = 'sanity-report' + const primaryKey = itemPrimaryKey(itemId) + const secondaryKey = itemSecondaryKey(itemId) + const detailsKey = itemDetailsKey(itemId) + + await navigateToItem(itemId) + assertPending(primaryKey, true) + assertPending(secondaryKey, true) + assertMarkerMissing(deferredResolvedMarker(primaryKey)) + await resolveAndWait(primaryKey) + assertPending(secondaryKey, true) + await resolveAndWait(secondaryKey) + + await navigateToDetails(itemId) + assertPending(detailsKey, true) + await resolveAndWait(detailsKey) + + await navigateToReport(reportId) + + for (let index = 0; index < REPORT_SECTION_COUNT; index++) { + const key = reportSectionKey(reportId, index) + assertPending(key, true) + assertMarkerMissing(deferredResolvedMarker(key)) + } + + await resolveReportAndWait(reportId) + } finally { + await after() + } + } + + return { + name: `client deferred await loop (${framework})`, + before, + run, + sanity, + after, + } +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/project.json b/benchmarks/client-nav/scenarios/deferred-await/solid/project.json new file mode 100644 index 0000000000..ce57f93906 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-deferred-await-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/setup.ts b/benchmarks/client-nav/scenarios/deferred-await/solid/setup.ts new file mode 100644 index 0000000000..495bb173d2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/setup.ts @@ -0,0 +1,18 @@ +import type * as App from './src/app' +import { createDeferredAwaitWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { + getDeferredRegistrySnapshot, + mountTestApp, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +} = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createDeferredAwaitWorkload('solid', mountTestApp, { + getDeferredRegistrySnapshot, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/deferred-await/solid/speed.bench.ts new file mode 100644 index 0000000000..f89ddfd4fa --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/speed.bench.ts @@ -0,0 +1,21 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +const deferredAwaitBenchOptions = { + ...clientNavBenchOptions, + warmupIterations: 1, +} + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...deferredAwaitBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/deferred-await/solid/speed.flame.ts new file mode 100644 index 0000000000..df681c2185 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/speed.flame.ts @@ -0,0 +1,17 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/app.tsx b/benchmarks/client-nav/scenarios/deferred-await/solid/src/app.tsx new file mode 100644 index 0000000000..bffd0a6125 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/app.tsx @@ -0,0 +1,28 @@ +import { RouterProvider } from '@tanstack/solid-router' +import { render } from 'solid-js/web' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} + +export { + getDeferredRegistrySnapshot, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +} from '../../shared' diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/deferred-value.tsx b/benchmarks/client-nav/scenarios/deferred-await/solid/src/deferred-value.tsx new file mode 100644 index 0000000000..5bd7a8010c --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/deferred-value.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'solid-js' +import { Await } from '@tanstack/solid-router' +import { + deferredFallbackMarker, + deferredResolvedMarker, + type DeferredPayload, +} from '../../shared' + +export function DeferredValue(props: { + markerKey: string + promise: Promise +}) { + return ( + + Loading {props.markerKey} + + } + > + + {(payload) => ( + + {payload.label} + + )} + + + ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/routeTree.ts b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routeTree.ts new file mode 100644 index 0000000000..a90990d581 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routeTree.ts @@ -0,0 +1,11 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as deferredIndexRoute } from './routes/deferred' +import { Route as itemRoute } from './routes/deferred.items.$itemId' +import { Route as detailsRoute } from './routes/deferred.items.$itemId.details' +import { Route as reportRoute } from './routes/deferred.reports.$reportId' + +export const routeTree = rootRoute.addChildren([ + deferredIndexRoute, + itemRoute.addChildren([detailsRoute]), + reportRoute, +]) diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/router.tsx b/benchmarks/client-nav/scenarios/deferred-await/solid/src/router.tsx new file mode 100644 index 0000000000..012c5d0dc7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/router.tsx @@ -0,0 +1,20 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { deferredInitialLocation, deferredRouterPendingMs } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [deferredInitialLocation], + }), + defaultPendingMs: deferredRouterPendingMs, + defaultPendingMinMs: deferredRouterPendingMs, + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..cb8d5a688d --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.items.$itemId.details.tsx b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.items.$itemId.details.tsx new file mode 100644 index 0000000000..f5a39cb77f --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.items.$itemId.details.tsx @@ -0,0 +1,30 @@ +import { createRoute } from '@tanstack/solid-router' +import { + createDetailsLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, +} from '../../../shared' +import { DeferredValue } from '../deferred-value' +import { Route as itemRoute } from './deferred.items.$itemId' + +export const Route = createRoute({ + getParentRoute: () => itemRoute, + path: 'details', + loader: ({ params }) => createDetailsLoaderData(params.itemId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: DetailsPage, +}) + +function DetailsPage() { + const data = Route.useLoaderData() + + return ( +
+ + {data().critical.label} + + +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.items.$itemId.tsx b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.items.$itemId.tsx new file mode 100644 index 0000000000..b5d8ac316e --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.items.$itemId.tsx @@ -0,0 +1,35 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { + createItemLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, +} from '../../../shared' +import { DeferredValue } from '../deferred-value' +import { Route as rootRoute } from './__root' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred/items/$itemId', + loader: ({ params }) => createItemLoaderData(params.itemId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: ItemPage, +}) + +function ItemPage() { + const data = Route.useLoaderData() + + return ( +
+ + {data().critical.label} + + + + +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.reports.$reportId.tsx new file mode 100644 index 0000000000..b9872a2b27 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.reports.$reportId.tsx @@ -0,0 +1,33 @@ +import { createRoute } from '@tanstack/solid-router' +import { + createReportLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, + type ReportSectionLoaderData, +} from '../../../shared' +import { DeferredValue } from '../deferred-value' +import { Route as rootRoute } from './__root' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred/reports/$reportId', + loader: ({ params }) => createReportLoaderData(params.reportId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: ReportPage, +}) + +function ReportPage() { + const data = Route.useLoaderData() + + return ( +
+ + {data().critical.label} + + {data().sections.map((section: ReportSectionLoaderData) => ( + + ))} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.tsx b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.tsx new file mode 100644 index 0000000000..1e4f547734 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/src/routes/deferred.tsx @@ -0,0 +1,12 @@ +import { createRoute } from '@tanstack/solid-router' +import { Route as rootRoute } from './__root' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred', + component: DeferredIndex, +}) + +function DeferredIndex() { + return
+} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/tsconfig.json b/benchmarks/client-nav/scenarios/deferred-await/solid/tsconfig.json new file mode 100644 index 0000000000..019ffe86c5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/solid/vite.config.ts b/benchmarks/client-nav/scenarios/deferred-await/solid/vite.config.ts new file mode 100644 index 0000000000..e1b2f66613 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/solid/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav deferred-await (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/project.json b/benchmarks/client-nav/scenarios/deferred-await/vue/project.json new file mode 100644 index 0000000000..cd2c93fb7d --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-deferred-await-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/setup.ts b/benchmarks/client-nav/scenarios/deferred-await/vue/setup.ts new file mode 100644 index 0000000000..0503f9752a --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/setup.ts @@ -0,0 +1,18 @@ +import type * as App from './src/app' +import { createDeferredAwaitWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { + getDeferredRegistrySnapshot, + mountTestApp, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +} = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createDeferredAwaitWorkload('vue', mountTestApp, { + getDeferredRegistrySnapshot, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/deferred-await/vue/speed.bench.ts new file mode 100644 index 0000000000..f89ddfd4fa --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/speed.bench.ts @@ -0,0 +1,21 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +const deferredAwaitBenchOptions = { + ...clientNavBenchOptions, + warmupIterations: 1, +} + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...deferredAwaitBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/deferred-await/vue/speed.flame.ts new file mode 100644 index 0000000000..df681c2185 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/speed.flame.ts @@ -0,0 +1,17 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/app.tsx b/benchmarks/client-nav/scenarios/deferred-await/vue/src/app.tsx new file mode 100644 index 0000000000..f06b3efd6a --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/app.tsx @@ -0,0 +1,35 @@ +import { RouterProvider } from '@tanstack/vue-router' +import { createApp } from 'vue' +import { getRouter } from './router' +import type {} from '@tanstack/router-core' + +export function mountTestApp(container: Element) { + const router = getRouter() + const vueApp = createApp({ + setup() { + return () => + }, + }) + let didUnmount = false + + vueApp.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + vueApp.unmount() + }, + } +} + +export { + getDeferredRegistrySnapshot, + resetDeferredRegistry, + resolveDeferredKey, + resolveReportDeferredKeys, +} from '../../shared' diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/deferred-value.tsx b/benchmarks/client-nav/scenarios/deferred-await/vue/src/deferred-value.tsx new file mode 100644 index 0000000000..bb983fd7c6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/deferred-value.tsx @@ -0,0 +1,37 @@ +import * as Vue from 'vue' +import { Await } from '@tanstack/vue-router' +import { + deferredFallbackMarker, + deferredResolvedMarker, + type DeferredPayload, +} from '../../shared' + +export function createDeferredValueNode( + markerKey: string, + promise: Promise, +) { + return ( + + {{ + default: () => ( + ( + + {payload.label} + + )} + /> + ), + fallback: () => ( + + Loading {markerKey} + + ), + }} + + ) +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/routeTree.ts b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routeTree.ts new file mode 100644 index 0000000000..a90990d581 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routeTree.ts @@ -0,0 +1,11 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as deferredIndexRoute } from './routes/deferred' +import { Route as itemRoute } from './routes/deferred.items.$itemId' +import { Route as detailsRoute } from './routes/deferred.items.$itemId.details' +import { Route as reportRoute } from './routes/deferred.reports.$reportId' + +export const routeTree = rootRoute.addChildren([ + deferredIndexRoute, + itemRoute.addChildren([detailsRoute]), + reportRoute, +]) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/router.tsx b/benchmarks/client-nav/scenarios/deferred-await/vue/src/router.tsx new file mode 100644 index 0000000000..047edd0a70 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/router.tsx @@ -0,0 +1,20 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { deferredInitialLocation, deferredRouterPendingMs } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [deferredInitialLocation], + }), + defaultPendingMs: deferredRouterPendingMs, + defaultPendingMinMs: deferredRouterPendingMs, + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..83e0043766 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/__root.tsx @@ -0,0 +1,12 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +const RootComponent = Vue.defineComponent({ + setup() { + return () => + }, +}) + +export const Route = createRootRoute({ + component: RootComponent, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.items.$itemId.details.tsx b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.items.$itemId.details.tsx new file mode 100644 index 0000000000..f4ed8adadf --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.items.$itemId.details.tsx @@ -0,0 +1,37 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + createDetailsLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, + type DetailsLoaderData, +} from '../../../shared' +import { createDeferredValueNode } from '../deferred-value' +import { Route as itemRoute } from './deferred.items.$itemId' + +const DetailsPage = Vue.defineComponent({ + setup() { + const data = Route.useLoaderData() as Vue.Ref + + return () => ( +
+ + {data.value.critical.label} + + {createDeferredValueNode(data.value.detailsKey, data.value.details)} +
+ ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => itemRoute, + path: 'details', + loader: ({ params }) => createDetailsLoaderData(params.itemId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: DetailsPage, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.items.$itemId.tsx b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.items.$itemId.tsx new file mode 100644 index 0000000000..f7b564c86f --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.items.$itemId.tsx @@ -0,0 +1,36 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { + createItemLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, + type ItemLoaderData, +} from '../../../shared' +import { createDeferredValueNode } from '../deferred-value' +import { Route as rootRoute } from './__root' + +const ItemPage = Vue.defineComponent({ + setup() { + const data = Route.useLoaderData() as Vue.Ref + + return () => ( +
+ + {data.value.critical.label} + + {createDeferredValueNode(data.value.primaryKey, data.value.primary)} + {createDeferredValueNode(data.value.secondaryKey, data.value.secondary)} + +
+ ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred/items/$itemId', + loader: ({ params }) => createItemLoaderData(params.itemId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: ItemPage, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.reports.$reportId.tsx new file mode 100644 index 0000000000..741b495c08 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.reports.$reportId.tsx @@ -0,0 +1,39 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + createReportLoaderData, + deferredRouteGcTime, + deferredRouteStaleTime, + type ReportLoaderData, +} from '../../../shared' +import { createDeferredValueNode } from '../deferred-value' +import { Route as rootRoute } from './__root' + +const ReportPage = Vue.defineComponent({ + setup() { + const data = Route.useLoaderData() as Vue.Ref + + return () => ( +
+ + {data.value.critical.label} + + {data.value.sections.map((section) => + createDeferredValueNode(section.key, section.promise), + )} +
+ ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred/reports/$reportId', + loader: ({ params }) => createReportLoaderData(params.reportId), + staleTime: deferredRouteStaleTime(), + gcTime: deferredRouteGcTime, + component: ReportPage, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.tsx b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.tsx new file mode 100644 index 0000000000..64cce674d7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/src/routes/deferred.tsx @@ -0,0 +1,15 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { Route as rootRoute } from './__root' + +const DeferredIndex = Vue.defineComponent({ + setup() { + return () =>
+ }, +}) + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/deferred', + component: DeferredIndex, +}) diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/tsconfig.json b/benchmarks/client-nav/scenarios/deferred-await/vue/tsconfig.json new file mode 100644 index 0000000000..2f82d2d127 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/deferred-await/vue/vite.config.ts b/benchmarks/client-nav/scenarios/deferred-await/vue/vite.config.ts new file mode 100644 index 0000000000..aeb03c0cb3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/deferred-await/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav deferred-await (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/react/project.json b/benchmarks/client-nav/scenarios/head-management/react/project.json new file mode 100644 index 0000000000..91562fab2a --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-head-management-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/setup.ts b/benchmarks/client-nav/scenarios/head-management/react/setup.ts new file mode 100644 index 0000000000..50dc8ad547 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createHeadManagementWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createHeadManagementWorkload( + 'react', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/head-management/react/speed.bench.ts b/benchmarks/client-nav/scenarios/head-management/react/speed.bench.ts new file mode 100644 index 0000000000..33f34acd3d --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav head-management', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/head-management/react/speed.flame.ts b/benchmarks/client-nav/scenarios/head-management/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/app.tsx b/benchmarks/client-nav/scenarios/head-management/react/src/app.tsx new file mode 100644 index 0000000000..538a2c129e --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/app.tsx @@ -0,0 +1,23 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/routeTree.ts b/benchmarks/client-nav/scenarios/head-management/react/src/routeTree.ts new file mode 100644 index 0000000000..6d1ec23214 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/routeTree.ts @@ -0,0 +1,14 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as headRoute } from './routes/head' +import { Route as articlesRoute } from './routes/head.articles' +import { Route as articleRoute } from './routes/head.articles.$articleId' +import { Route as productRoute } from './routes/head.products.$productId' +import { Route as settingsRoute } from './routes/head.settings' + +export const routeTree = rootRoute.addChildren([ + headRoute.addChildren([ + articlesRoute.addChildren([articleRoute]), + productRoute, + settingsRoute, + ]), +]) diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/router.tsx b/benchmarks/client-nav/scenarios/head-management/react/src/router.tsx new file mode 100644 index 0000000000..ca61c03132 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { initialLocation } from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/head-management/react/src/routes/__root.tsx new file mode 100644 index 0000000000..95a2f28d4c --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/routes/__root.tsx @@ -0,0 +1,17 @@ +import { HeadContent, Outlet, createRootRoute } from '@tanstack/react-router' +import { createPortal } from 'react-dom' +import { normalizeHeadSearch } from '../../../shared.ts' + +export const Route = createRootRoute({ + validateSearch: normalizeHeadSearch, + component: RootComponent, +}) + +function RootComponent() { + return ( + <> + {createPortal(, document.head)} + + + ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.articles.$articleId.tsx b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.articles.$articleId.tsx new file mode 100644 index 0000000000..e4a83259d2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.articles.$articleId.tsx @@ -0,0 +1,27 @@ +import { createRoute } from '@tanstack/react-router' +import { createArticleHead, createHeadLoaderData } from '../../../shared.ts' +import { Route as articlesRoute } from './head.articles' + +export const Route = createRoute({ + getParentRoute: () => articlesRoute, + path: '$articleId', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('article', params.articleId, deps), + head: ({ params, loaderData }) => + createArticleHead(params.articleId, loaderData!), + component: ArticlePage, +}) + +function ArticlePage() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.articles.tsx b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.articles.tsx new file mode 100644 index 0000000000..e405bb3249 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.articles.tsx @@ -0,0 +1,25 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { createArticlesHead, createHeadLoaderData } from '../../../shared.ts' +import { Route as headRoute } from './head' + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'articles', + loaderDeps: ({ search }) => search, + loader: ({ deps }) => createHeadLoaderData('articles', 'list', deps), + head: ({ loaderData }) => createArticlesHead(loaderData!), + component: ArticlesPage, +}) + +function ArticlesPage() { + const loaderData = Route.useLoaderData() + + return ( +
+ +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.products.$productId.tsx b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.products.$productId.tsx new file mode 100644 index 0000000000..6b4bf38587 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.products.$productId.tsx @@ -0,0 +1,27 @@ +import { createRoute } from '@tanstack/react-router' +import { createHeadLoaderData, createProductHead } from '../../../shared.ts' +import { Route as headRoute } from './head' + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'products/$productId', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('product', params.productId, deps), + head: ({ params, loaderData }) => + createProductHead(params.productId, loaderData!), + component: ProductPage, +}) + +function ProductPage() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.settings.tsx b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.settings.tsx new file mode 100644 index 0000000000..cd345df945 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.settings.tsx @@ -0,0 +1,26 @@ +import { createRoute } from '@tanstack/react-router' +import { createHeadLoaderData, createSettingsHead } from '../../../shared.ts' +import { Route as headRoute } from './head' + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'settings/{-$tab}', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('settings', params.tab ?? 'general', deps), + head: ({ params, loaderData }) => createSettingsHead(params.tab, loaderData!), + component: SettingsPage, +}) + +function SettingsPage() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.tsx b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.tsx new file mode 100644 index 0000000000..aaf4d5c6b2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/src/routes/head.tsx @@ -0,0 +1,22 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { createHeadLoaderData, createHeadSectionHead } from '../../../shared.ts' +import { Route as rootRoute } from './__root' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/head', + loaderDeps: ({ search }) => search, + loader: ({ deps }) => createHeadLoaderData('section', 'root', deps), + head: ({ loaderData }) => createHeadSectionHead(loaderData!), + component: HeadLayout, +}) + +function HeadLayout() { + const loaderData = Route.useLoaderData() + + return ( +
+ +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/tsconfig.json b/benchmarks/client-nav/scenarios/head-management/react/tsconfig.json new file mode 100644 index 0000000000..e5056ec745 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/head-management/react/vite.config.ts b/benchmarks/client-nav/scenarios/head-management/react/vite.config.ts new file mode 100644 index 0000000000..5a3facb0d2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav head-management (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/shared.ts b/benchmarks/client-nav/scenarios/head-management/shared.ts new file mode 100644 index 0000000000..ab49339c3d --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/shared.ts @@ -0,0 +1,1013 @@ +import type { NavigateOptions } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' +import { + createClientNavLifecycle, + warnClientNavDevMode, + type Framework, + type MountTestApp, +} from '#client-nav/lifecycle' + +export type HeadSearch = { + variant: string + seed: number + panel: string +} + +export type HeadLoaderData = { + kind: string + resourceId: string + seed: number + label: string + checksum: number +} + +type HeadExpectation = { + route: string + title: string + description: string + sharedContent: string + canonical: string + productId?: string +} + +type HeadSnapshot = { + html: string + title: string +} + +type NavigationStep = { + label: string + options: Record +} + +type ScrollGlobal = typeof globalThis & { + scrollTo?: typeof window.scrollTo +} + +type HeadLinkEntry = Record +type HeadScriptEntry = Record + +const scenarioRandom = createDeterministicRandom(0x14ead123) + +export const headScenarioSlug = 'head-management' +export const sharedMetaName = 'head-benchmark-shared' +export const descriptionMetaName = 'description' +export const productMetaCount = 18 +export const productScriptCount = 5 +export const navigationStepsPerCycle = 6 +export const navigationCyclesPerRun = 3 +export const navigationsPerBenchRun = + navigationStepsPerCycle * navigationCyclesPerRun + +export const settingsTabs = ['profile', 'billing', 'security', 'members'] +export const initialHeadSearch = createHeadSearch(0, 'initial') +export const initialLocation = createHeadHref('/head', initialHeadSearch) + +function createSegments(prefix: string, count: number) { + const values: Array = [] + + for (let index = 0; index < count; index++) { + values.push(`${prefix}-${index}-${randomSegment(scenarioRandom)}`) + } + + return values +} + +export const articleIds = createSegments('article', 96) +export const productIds = createSegments('product', 72) + +function normalizePositiveInteger(value: unknown, fallback: number) { + const numberValue = Number(value) + + if (Number.isFinite(numberValue) && numberValue > 0) { + return Math.trunc(numberValue) + } + + return fallback +} + +function normalizeString(value: unknown, fallback: string) { + if (typeof value === 'string' && value.length > 0) { + return value + } + + return fallback +} + +export function normalizeHeadSearch( + search: Record, +): HeadSearch { + return { + variant: normalizeString(search.variant, 'default'), + seed: normalizePositiveInteger(search.seed, 1), + panel: normalizeString(search.panel, 'main'), + } +} + +export function runHeadComputation(value: string, seed: number) { + let hash = seed >>> 0 + + for (let round = 0; round < 24; round++) { + for (let index = 0; index < value.length; index++) { + hash = (hash ^ value.charCodeAt(index) ^ ((round + 1) * 2654435761)) >>> 0 + hash = (hash * 1664525 + 1013904223 + index) >>> 0 + } + } + + return hash +} + +export function createHeadSearch(cycle: number, label: string): HeadSearch { + const checksum = runHeadComputation(`${label}:${cycle}`, cycle + 29) + + return { + variant: `${label}-${cycle % 7}`, + seed: (checksum % 50_000) + 1, + panel: `panel-${checksum % 11}`, + } +} + +export function stringifyHeadSearch(search: HeadSearch) { + const params = new URLSearchParams() + params.set('variant', search.variant) + params.set('seed', `${search.seed}`) + params.set('panel', search.panel) + + return `?${params.toString()}` +} + +export function createHeadHref(pathname: string, search: HeadSearch) { + return `${pathname}${stringifyHeadSearch(search)}` +} + +export function createHeadLoaderData( + kind: string, + resourceId: string, + search: HeadSearch, +): HeadLoaderData { + const label = `${kind}-${search.variant}-${search.panel}` + const checksum = runHeadComputation( + `${label}:${resourceId}:${search.seed}`, + search.seed, + ) + + return { + kind, + resourceId, + seed: search.seed, + label, + checksum, + } +} + +function createDescription(route: string, loaderData: HeadLoaderData) { + return `${route} head ${loaderData.resourceId} ${loaderData.checksum}` +} + +function createSharedMetaContent(route: string, loaderData: HeadLoaderData) { + return `${route}-${loaderData.checksum}-${loaderData.seed}` +} + +function createSectionTitle(loaderData: HeadLoaderData) { + return `Client Head Section ${loaderData.checksum}` +} + +function createArticleListTitle(loaderData: HeadLoaderData) { + return `Client Head Articles ${loaderData.checksum}` +} + +function createArticleTitle(articleId: string, loaderData: HeadLoaderData) { + return `Client Head Article ${articleId} ${loaderData.checksum}` +} + +function createProductTitle(productId: string, loaderData: HeadLoaderData) { + return `Client Head Product ${productId} ${loaderData.checksum}` +} + +function createSettingsTitle( + tab: string | undefined, + loaderData: HeadLoaderData, +) { + return `Client Head Settings ${tab ?? 'general'} ${loaderData.checksum}` +} + +function createScenarioMeta( + route: string, + loaderData: HeadLoaderData, + title: string, +) { + return [ + { title }, + { + name: descriptionMetaName, + content: createDescription(route, loaderData), + 'data-head-scenario': headScenarioSlug, + 'data-head-route': route, + }, + { + name: sharedMetaName, + content: createSharedMetaContent(route, loaderData), + 'data-head-scenario': headScenarioSlug, + 'data-head-route': route, + }, + ] +} + +function createSharedPreloadLink() { + return { + rel: 'preload', + as: 'fetch', + href: '/head-assets/shared-head-payload.json', + 'data-head-shared-link': 'true', + } +} + +function createRouteLinks( + route: string, + canonical: string, + loaderData: HeadLoaderData, + preloadCount: number, +) { + const links: Array = [ + { + rel: 'canonical', + href: canonical, + 'data-head-scenario': headScenarioSlug, + 'data-head-canonical': route, + }, + createSharedPreloadLink(), + ] + + for (let index = 0; index < preloadCount; index++) { + links.push({ + rel: 'preload', + as: index % 2 === 0 ? 'fetch' : 'image', + href: `/head-assets/${route}-${loaderData.checksum}-${index}.json`, + 'data-head-scenario': headScenarioSlug, + 'data-head-route-link': route, + 'data-head-link-index': `${index}`, + }) + } + + return links +} + +function createSharedScript() { + return { + type: 'application/json', + 'data-head-shared-script': 'true', + children: '{"scenario":"head-management","shared":true}', + } +} + +function createProductScripts(productId: string, loaderData: HeadLoaderData) { + const scripts: Array = [createSharedScript()] + + for (let index = 0; index < productScriptCount; index++) { + scripts.push({ + type: 'application/json', + 'data-head-product-script': 'true', + 'data-head-product-id': productId, + 'data-head-script-index': `${index}`, + children: JSON.stringify({ + scenario: headScenarioSlug, + productId, + index, + checksum: runHeadComputation( + `${productId}:script:${index}`, + loaderData.checksum + index, + ), + }), + }) + } + + return scripts +} + +function createProductMeta(productId: string, loaderData: HeadLoaderData) { + const meta: Array> = [] + + for (let index = 0; index < productMetaCount; index++) { + const checksum = runHeadComputation( + `${productId}:meta:${index}`, + loaderData.seed + index, + ) + meta.push({ + name: `product:attribute:${index}`, + content: `${productId}-${checksum}`, + 'data-head-scenario': headScenarioSlug, + 'data-head-product-meta': 'true', + 'data-head-product-id': productId, + 'data-head-product-index': `${index}`, + }) + } + + return meta +} + +export function createHeadSectionHead(loaderData: HeadLoaderData) { + return { + meta: [ + ...createScenarioMeta( + 'section', + loaderData, + createSectionTitle(loaderData), + ), + { + property: 'og:site_name', + content: 'TanStack Router Client Head Benchmark', + 'data-head-scenario': headScenarioSlug, + }, + { + name: 'theme-color', + content: `#${(loaderData.checksum % 0xffffff).toString(16).padStart(6, '0')}`, + 'data-head-scenario': headScenarioSlug, + }, + ], + links: createRouteLinks('section', '/head', loaderData, 3), + scripts: [createSharedScript()], + } +} + +export function createArticlesHead(loaderData: HeadLoaderData) { + return { + meta: [ + ...createScenarioMeta( + 'articles', + loaderData, + createArticleListTitle(loaderData), + ), + { + property: 'og:title', + content: `Articles ${loaderData.checksum}`, + 'data-head-scenario': headScenarioSlug, + 'data-head-route': 'articles', + }, + { + property: 'og:type', + content: 'website', + 'data-head-scenario': headScenarioSlug, + 'data-head-route': 'articles', + }, + { + name: 'article:list-count', + content: `${articleIds.length}`, + 'data-head-scenario': headScenarioSlug, + }, + ], + links: createRouteLinks('articles', '/head/articles', loaderData, 8), + scripts: [createSharedScript()], + } +} + +export function createArticleHead( + articleId: string, + loaderData: HeadLoaderData, +) { + return { + meta: [ + ...createScenarioMeta( + 'article', + loaderData, + createArticleTitle(articleId, loaderData), + ), + { + property: 'og:title', + content: `Article ${articleId} ${loaderData.checksum}`, + 'data-head-scenario': headScenarioSlug, + 'data-head-route': 'article', + }, + { + property: 'og:type', + content: 'article', + 'data-head-scenario': headScenarioSlug, + 'data-head-route': 'article', + }, + { + property: 'article:published_time', + content: `2024-01-${String((loaderData.checksum % 28) + 1).padStart(2, '0')}`, + 'data-head-scenario': headScenarioSlug, + }, + { + name: 'article:id', + content: articleId, + 'data-head-scenario': headScenarioSlug, + }, + { + 'script:ld+json': { + '@context': 'https://schema.org', + '@type': 'Article', + headline: articleId, + identifier: `${articleId}-${loaderData.checksum}`, + }, + }, + ], + links: createRouteLinks( + 'article', + `/head/articles/${articleId}`, + loaderData, + 5, + ), + scripts: [createSharedScript()], + } +} + +export function createProductHead( + productId: string, + loaderData: HeadLoaderData, +) { + return { + meta: [ + ...createScenarioMeta( + 'product', + loaderData, + createProductTitle(productId, loaderData), + ), + { + property: 'og:type', + content: 'product', + 'data-head-scenario': headScenarioSlug, + 'data-head-route': 'product', + }, + { + property: 'product:retailer_item_id', + content: productId, + 'data-head-scenario': headScenarioSlug, + }, + ...createProductMeta(productId, loaderData), + ], + links: createRouteLinks( + 'product', + `/head/products/${productId}`, + loaderData, + 7, + ), + scripts: createProductScripts(productId, loaderData), + } +} + +export function createSettingsHead( + tab: string | undefined, + loaderData: HeadLoaderData, +) { + const normalizedTab = tab ?? 'general' + + return { + meta: [ + ...createScenarioMeta( + 'settings', + loaderData, + createSettingsTitle(tab, loaderData), + ), + { + name: 'settings:tab', + content: normalizedTab, + 'data-head-scenario': headScenarioSlug, + }, + { + property: 'og:title', + content: `Settings ${normalizedTab}`, + 'data-head-scenario': headScenarioSlug, + 'data-head-route': 'settings', + }, + ], + links: createRouteLinks( + 'settings', + tab ? `/head/settings/${tab}` : '/head/settings', + loaderData, + 4, + ), + scripts: [createSharedScript()], + } +} + +function createSectionExpectation(search: HeadSearch): HeadExpectation { + const loaderData = createHeadLoaderData('section', 'root', search) + + return { + route: 'section', + title: createSectionTitle(loaderData), + description: createDescription('section', loaderData), + sharedContent: createSharedMetaContent('section', loaderData), + canonical: '/head', + } +} + +function createArticleExpectation( + articleId: string, + search: HeadSearch, +): HeadExpectation { + const loaderData = createHeadLoaderData('article', articleId, search) + + return { + route: 'article', + title: createArticleTitle(articleId, loaderData), + description: createDescription('article', loaderData), + sharedContent: createSharedMetaContent('article', loaderData), + canonical: `/head/articles/${articleId}`, + } +} + +function createProductExpectation( + productId: string, + search: HeadSearch, +): HeadExpectation { + const loaderData = createHeadLoaderData('product', productId, search) + + return { + route: 'product', + title: createProductTitle(productId, loaderData), + description: createDescription('product', loaderData), + sharedContent: createSharedMetaContent('product', loaderData), + canonical: `/head/products/${productId}`, + productId, + } +} + +function createSettingsExpectation( + tab: string | undefined, + search: HeadSearch, +): HeadExpectation { + const resourceId = tab ?? 'general' + const loaderData = createHeadLoaderData('settings', resourceId, search) + + return { + route: 'settings', + title: createSettingsTitle(tab, loaderData), + description: createDescription('settings', loaderData), + sharedContent: createSharedMetaContent('settings', loaderData), + canonical: tab ? `/head/settings/${tab}` : '/head/settings', + } +} + +function createNavigationCycle(cycle: number): Array { + const firstArticleId = articleIds[(cycle * 5 + 1) % articleIds.length]! + const secondArticleId = articleIds[(cycle * 5 + 2) % articleIds.length]! + const productId = productIds[(cycle * 7 + 3) % productIds.length]! + const tab = settingsTabs[cycle % settingsTabs.length]! + + return [ + { + label: `articles-list-${cycle}`, + options: { + to: '/head/articles', + search: createHeadSearch(cycle, 'articles-list'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `article-a-${cycle}`, + options: { + to: '/head/articles/$articleId', + params: { articleId: firstArticleId }, + search: createHeadSearch(cycle, 'article-a'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `article-b-${cycle}`, + options: { + to: '/head/articles/$articleId', + params: { articleId: secondArticleId }, + search: createHeadSearch(cycle, 'article-b'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `product-${cycle}`, + options: { + to: '/head/products/$productId', + params: { productId }, + search: createHeadSearch(cycle, 'product'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `settings-tab-${cycle}`, + options: { + to: '/head/settings/{-$tab}', + params: { tab }, + search: createHeadSearch(cycle, 'settings-tab'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `settings-empty-${cycle}`, + options: { + to: '/head/settings/{-$tab}', + params: { tab: undefined }, + search: createHeadSearch(cycle, 'settings-empty'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + ] +} + +const navigationSteps = Array.from( + { length: navigationCyclesPerRun }, + (_, cycle) => createNavigationCycle(cycle), +).flat() + +function captureDocumentHead(): HeadSnapshot { + return { + html: document.head.innerHTML, + title: document.title, + } +} + +function restoreDocumentHead(snapshot: HeadSnapshot) { + document.head.innerHTML = snapshot.html + + if (document.title !== snapshot.title) { + document.title = snapshot.title + } +} + +function patchMissingScrollToGlobal() { + const scrollGlobal = globalThis as ScrollGlobal + const hadScrollTo = Object.prototype.hasOwnProperty.call( + scrollGlobal, + 'scrollTo', + ) + const previousScrollTo = scrollGlobal.scrollTo + + if (typeof previousScrollTo === 'function') { + return () => {} + } + + const fallbackScrollTo = + typeof window !== 'undefined' && typeof window.scrollTo === 'function' + ? window.scrollTo.bind(window) + : () => {} + + Object.defineProperty(scrollGlobal, 'scrollTo', { + value: fallbackScrollTo, + configurable: true, + writable: true, + }) + + let restored = false + + return () => { + if (restored) { + return + } + + restored = true + + if (hadScrollTo) { + Object.defineProperty(scrollGlobal, 'scrollTo', { + value: previousScrollTo, + configurable: true, + writable: true, + }) + return + } + + delete (scrollGlobal as { scrollTo?: typeof window.scrollTo }).scrollTo + } +} + +function findRouteMarker(container: ParentNode, route: string) { + return container.querySelector(`[data-route-marker="${route}"]`) +} + +function routeMarkerMatches( + container: ParentNode, + route: string, + expectedDataset: Record = {}, +) { + const marker = findRouteMarker(container, route) + + if (!marker) { + return false + } + + for (const [key, value] of Object.entries(expectedDataset)) { + if (marker.dataset[key] !== value) { + return false + } + } + + return true +} + +function assertRouteMarker( + container: ParentNode, + route: string, + expectedDataset: Record = {}, +) { + const marker = findRouteMarker(container, route) + + if (!marker) { + throw new Error(`Expected head-management route marker ${route}`) + } + + for (const [key, value] of Object.entries(expectedDataset)) { + if (marker.dataset[key] !== value) { + throw new Error( + `Expected head-management marker ${route} ${key}=${value}, got ${marker.dataset[key]}`, + ) + } + } +} + +function readScenarioMeta(name: string) { + return document.head.querySelector( + `meta[name="${name}"][data-head-scenario="${headScenarioSlug}"]`, + ) +} + +function countScenarioMeta(name: string) { + return document.head.querySelectorAll( + `meta[name="${name}"][data-head-scenario="${headScenarioSlug}"]`, + ).length +} + +function headMatchesExpectation(expectation: HeadExpectation) { + if (document.title !== expectation.title) { + return false + } + + const description = readScenarioMeta(descriptionMetaName) + if (description?.getAttribute('content') !== expectation.description) { + return false + } + + const shared = readScenarioMeta(sharedMetaName) + if (shared?.getAttribute('content') !== expectation.sharedContent) { + return false + } + + const canonical = document.head.querySelector( + `link[rel="canonical"][data-head-canonical="${expectation.route}"]`, + ) + if (canonical?.getAttribute('href') !== expectation.canonical) { + return false + } + + if ( + document.head.querySelectorAll('link[data-head-shared-link="true"]') + .length !== 1 + ) { + return false + } + + if (expectation.productId) { + const productMeta = document.head.querySelectorAll( + `meta[data-head-product-meta="true"][data-head-product-id="${expectation.productId}"]`, + ) + + if (productMeta.length !== productMetaCount) { + return false + } + } + + return true +} + +function assertHeadExpectation(expectation: HeadExpectation) { + if (document.title !== expectation.title) { + throw new Error( + `Expected document title ${expectation.title}, got ${document.title}`, + ) + } + + if (countScenarioMeta(descriptionMetaName) !== 1) { + throw new Error('Expected exactly one scenario description meta tag') + } + + if (countScenarioMeta(sharedMetaName) !== 1) { + throw new Error('Expected exactly one deduped shared scenario meta tag') + } + + const description = readScenarioMeta(descriptionMetaName) + if (description?.getAttribute('content') !== expectation.description) { + throw new Error( + `Expected description ${expectation.description}, got ${description?.getAttribute('content')}`, + ) + } + + const shared = readScenarioMeta(sharedMetaName) + if (shared?.getAttribute('content') !== expectation.sharedContent) { + throw new Error( + `Expected shared meta ${expectation.sharedContent}, got ${shared?.getAttribute('content')}`, + ) + } + + const canonical = document.head.querySelector( + `link[rel="canonical"][data-head-canonical="${expectation.route}"]`, + ) + if (canonical?.getAttribute('href') !== expectation.canonical) { + throw new Error( + `Expected canonical ${expectation.canonical}, got ${canonical?.getAttribute('href')}`, + ) + } + + const sharedLinks = document.head.querySelectorAll( + 'link[data-head-shared-link="true"]', + ) + if (sharedLinks.length !== 1) { + throw new Error( + `Expected one deduped shared link, got ${sharedLinks.length}`, + ) + } + + if (expectation.productId) { + const productMeta = document.head.querySelectorAll( + `meta[data-head-product-meta="true"][data-head-product-id="${expectation.productId}"]`, + ) + + if (productMeta.length !== productMetaCount) { + throw new Error( + `Expected ${productMetaCount} product meta tags, got ${productMeta.length}`, + ) + } + } +} + +export function createHeadManagementWorkload( + framework: Framework, + mountTestApp: MountTestApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ + mountTestApp, + timeoutMs: 4_000, + }) + let stepIndex = 0 + let headSnapshot: HeadSnapshot | undefined = undefined + let restoreScrollTo: (() => void) | undefined = undefined + + async function after() { + const snapshot = headSnapshot + const restoreScroll = restoreScrollTo + headSnapshot = undefined + restoreScrollTo = undefined + let teardownError: unknown = undefined + + try { + await lifecycle.after() + } catch (error) { + teardownError = error + } finally { + if (snapshot) { + restoreDocumentHead(snapshot) + } + + if (restoreScroll) { + restoreScroll() + } + } + + if (teardownError) { + throw teardownError + } + } + + async function waitForRouteMarker( + route: string, + expectedDataset: Record = {}, + ) { + await lifecycle.waitForCounter( + () => + routeMarkerMatches(lifecycle.getContainer(), route, expectedDataset) + ? 1 + : 0, + 1, + { label: `head-management route marker ${route}` }, + ) + assertRouteMarker(lifecycle.getContainer(), route, expectedDataset) + } + + async function waitForHeadExpectation(expectation: HeadExpectation) { + await lifecycle.waitForCounter( + () => (headMatchesExpectation(expectation) ? 1 : 0), + 1, + { label: `head-management head ${expectation.route}` }, + ) + assertHeadExpectation(expectation) + } + + async function before() { + await after() + stepIndex = 0 + headSnapshot = captureDocumentHead() + restoreScrollTo = patchMissingScrollToGlobal() + + try { + await lifecycle.before() + await waitForRouteMarker('head') + await waitForHeadExpectation(createSectionExpectation(initialHeadSearch)) + } catch (error) { + await after() + throw error + } + } + + async function runSteps(count: number) { + for (let index = 0; index < count; index++) { + const step = navigationSteps[stepIndex % navigationSteps.length]! + stepIndex += 1 + + await lifecycle.navigate(step.options as NavigateOptions, { + wait: 'rendered', + label: step.label, + }) + } + } + + async function sanity() { + await before() + + try { + const articleId = articleIds[5]! + const articleSearch = createHeadSearch(23, 'sanity-article') + const productId = productIds[9]! + const productSearch = createHeadSearch(23, 'sanity-product') + const settingsSearch = createHeadSearch(23, 'sanity-settings-empty') + + await lifecycle.navigate( + { + to: '/head/articles/$articleId', + params: { articleId }, + search: articleSearch, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + } as NavigateOptions, + { wait: 'rendered', label: 'sanity article detail' }, + ) + await waitForRouteMarker('article', { articleId }) + await waitForHeadExpectation( + createArticleExpectation(articleId, articleSearch), + ) + const articleTitle = document.title + + await lifecycle.navigate( + { + to: '/head/products/$productId', + params: { productId }, + search: productSearch, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + } as NavigateOptions, + { wait: 'rendered', label: 'sanity product detail' }, + ) + await waitForRouteMarker('product', { productId }) + await waitForHeadExpectation( + createProductExpectation(productId, productSearch), + ) + + if (document.title === articleTitle) { + throw new Error('Expected dynamic product navigation to change title') + } + + await lifecycle.navigate( + { + to: '/head/settings/{-$tab}', + params: { tab: undefined }, + search: settingsSearch, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + } as NavigateOptions, + { wait: 'rendered', label: 'sanity settings optional empty' }, + ) + await waitForRouteMarker('settings', { tab: 'none' }) + await waitForHeadExpectation( + createSettingsExpectation(undefined, settingsSearch), + ) + + await runSteps(navigationStepsPerCycle) + } finally { + await after() + } + } + + return { + name: `client head management loop (${framework})`, + before, + run: () => runSteps(navigationsPerBenchRun), + sanity, + after, + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/project.json b/benchmarks/client-nav/scenarios/head-management/solid/project.json new file mode 100644 index 0000000000..7dfa7ebd8f --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-head-management-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/setup.ts b/benchmarks/client-nav/scenarios/head-management/solid/setup.ts new file mode 100644 index 0000000000..7c5f3debb2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createHeadManagementWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createHeadManagementWorkload( + 'solid', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/head-management/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/head-management/solid/speed.bench.ts new file mode 100644 index 0000000000..33f34acd3d --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav head-management', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/head-management/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/head-management/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/app.tsx b/benchmarks/client-nav/scenarios/head-management/solid/src/app.tsx new file mode 100644 index 0000000000..7c5f5713f4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/app.tsx @@ -0,0 +1,21 @@ +import { RouterProvider } from '@tanstack/solid-router' +import { render } from 'solid-js/web' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/routeTree.ts b/benchmarks/client-nav/scenarios/head-management/solid/src/routeTree.ts new file mode 100644 index 0000000000..6d1ec23214 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/routeTree.ts @@ -0,0 +1,14 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as headRoute } from './routes/head' +import { Route as articlesRoute } from './routes/head.articles' +import { Route as articleRoute } from './routes/head.articles.$articleId' +import { Route as productRoute } from './routes/head.products.$productId' +import { Route as settingsRoute } from './routes/head.settings' + +export const routeTree = rootRoute.addChildren([ + headRoute.addChildren([ + articlesRoute.addChildren([articleRoute]), + productRoute, + settingsRoute, + ]), +]) diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/router.tsx b/benchmarks/client-nav/scenarios/head-management/solid/src/router.tsx new file mode 100644 index 0000000000..eb92e23d72 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { initialLocation } from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..a2b7231b9b --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/__root.tsx @@ -0,0 +1,16 @@ +import { HeadContent, Outlet, createRootRoute } from '@tanstack/solid-router' +import { normalizeHeadSearch } from '../../../shared.ts' + +export const Route = createRootRoute({ + validateSearch: normalizeHeadSearch, + component: RootComponent, +}) + +function RootComponent() { + return ( + <> + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.articles.$articleId.tsx b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.articles.$articleId.tsx new file mode 100644 index 0000000000..bafd666c1d --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.articles.$articleId.tsx @@ -0,0 +1,27 @@ +import { createRoute } from '@tanstack/solid-router' +import { createArticleHead, createHeadLoaderData } from '../../../shared.ts' +import { Route as articlesRoute } from './head.articles' + +export const Route = createRoute({ + getParentRoute: () => articlesRoute, + path: '$articleId', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('article', params.articleId, deps), + head: ({ params, loaderData }) => + createArticleHead(params.articleId, loaderData!), + component: ArticlePage, +}) + +function ArticlePage() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.articles.tsx b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.articles.tsx new file mode 100644 index 0000000000..ec514c406e --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.articles.tsx @@ -0,0 +1,25 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { createArticlesHead, createHeadLoaderData } from '../../../shared.ts' +import { Route as headRoute } from './head' + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'articles', + loaderDeps: ({ search }) => search, + loader: ({ deps }) => createHeadLoaderData('articles', 'list', deps), + head: ({ loaderData }) => createArticlesHead(loaderData!), + component: ArticlesPage, +}) + +function ArticlesPage() { + const loaderData = Route.useLoaderData() + + return ( +
+ +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.products.$productId.tsx b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.products.$productId.tsx new file mode 100644 index 0000000000..0379197227 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.products.$productId.tsx @@ -0,0 +1,27 @@ +import { createRoute } from '@tanstack/solid-router' +import { createHeadLoaderData, createProductHead } from '../../../shared.ts' +import { Route as headRoute } from './head' + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'products/$productId', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('product', params.productId, deps), + head: ({ params, loaderData }) => + createProductHead(params.productId, loaderData!), + component: ProductPage, +}) + +function ProductPage() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.settings.tsx b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.settings.tsx new file mode 100644 index 0000000000..84d0950497 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.settings.tsx @@ -0,0 +1,26 @@ +import { createRoute } from '@tanstack/solid-router' +import { createHeadLoaderData, createSettingsHead } from '../../../shared.ts' +import { Route as headRoute } from './head' + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'settings/{-$tab}', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('settings', params.tab ?? 'general', deps), + head: ({ params, loaderData }) => createSettingsHead(params.tab, loaderData!), + component: SettingsPage, +}) + +function SettingsPage() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.tsx b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.tsx new file mode 100644 index 0000000000..55567655bf --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/src/routes/head.tsx @@ -0,0 +1,25 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { createHeadLoaderData, createHeadSectionHead } from '../../../shared.ts' +import { Route as rootRoute } from './__root' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/head', + loaderDeps: ({ search }) => search, + loader: ({ deps }) => createHeadLoaderData('section', 'root', deps), + head: ({ loaderData }) => createHeadSectionHead(loaderData!), + component: HeadLayout, +}) + +function HeadLayout() { + const loaderData = Route.useLoaderData() + + return ( +
+ +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/tsconfig.json b/benchmarks/client-nav/scenarios/head-management/solid/tsconfig.json new file mode 100644 index 0000000000..b549cd9fe8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/head-management/solid/vite.config.ts b/benchmarks/client-nav/scenarios/head-management/solid/vite.config.ts new file mode 100644 index 0000000000..4d8e58a6a3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/solid/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav head-management (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/project.json b/benchmarks/client-nav/scenarios/head-management/vue/project.json new file mode 100644 index 0000000000..b2bbdc7417 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-head-management-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/vue/setup.ts b/benchmarks/client-nav/scenarios/head-management/vue/setup.ts new file mode 100644 index 0000000000..c06370c3f9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createHeadManagementWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createHeadManagementWorkload( + 'vue', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/head-management/vue/speed.bench.ts new file mode 100644 index 0000000000..33f34acd3d --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav head-management', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/head-management/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/app.tsx b/benchmarks/client-nav/scenarios/head-management/vue/src/app.tsx new file mode 100644 index 0000000000..ae5a77fd40 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/app.tsx @@ -0,0 +1,28 @@ +import { RouterProvider } from '@tanstack/vue-router' +import { createApp } from 'vue' +import { getRouter } from './router' +import type {} from '@tanstack/router-core' + +export function mountTestApp(container: Element) { + const router = getRouter() + const vueApp = createApp({ + setup() { + return () => + }, + }) + let didUnmount = false + + vueApp.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + vueApp.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/routeTree.ts b/benchmarks/client-nav/scenarios/head-management/vue/src/routeTree.ts new file mode 100644 index 0000000000..6d1ec23214 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/routeTree.ts @@ -0,0 +1,14 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as headRoute } from './routes/head' +import { Route as articlesRoute } from './routes/head.articles' +import { Route as articleRoute } from './routes/head.articles.$articleId' +import { Route as productRoute } from './routes/head.products.$productId' +import { Route as settingsRoute } from './routes/head.settings' + +export const routeTree = rootRoute.addChildren([ + headRoute.addChildren([ + articlesRoute.addChildren([articleRoute]), + productRoute, + settingsRoute, + ]), +]) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/router.tsx b/benchmarks/client-nav/scenarios/head-management/vue/src/router.tsx new file mode 100644 index 0000000000..8c27bc5aff --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { initialLocation } from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..416e452dac --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/__root.tsx @@ -0,0 +1,22 @@ +import * as Vue from 'vue' +import { Teleport } from 'vue' +import { HeadContent, Outlet, createRootRoute } from '@tanstack/vue-router' +import { normalizeHeadSearch } from '../../../shared.ts' + +const RootComponent = Vue.defineComponent({ + setup() { + return () => ( + <> + + + + + + ) + }, +}) + +export const Route = createRootRoute({ + validateSearch: normalizeHeadSearch, + component: RootComponent, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.articles.$articleId.tsx b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.articles.$articleId.tsx new file mode 100644 index 0000000000..0d37e934f0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.articles.$articleId.tsx @@ -0,0 +1,30 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { createArticleHead, createHeadLoaderData } from '../../../shared.ts' +import { Route as articlesRoute } from './head.articles' + +const ArticlePage = Vue.defineComponent({ + setup() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return () => ( +
+ ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => articlesRoute, + path: '$articleId', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('article', params.articleId, deps), + head: ({ params, loaderData }) => + createArticleHead(params.articleId, loaderData!), + component: ArticlePage, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.articles.tsx b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.articles.tsx new file mode 100644 index 0000000000..cf917d17bc --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.articles.tsx @@ -0,0 +1,28 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { createArticlesHead, createHeadLoaderData } from '../../../shared.ts' +import { Route as headRoute } from './head' + +const ArticlesPage = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( +
+ +
+ ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'articles', + loaderDeps: ({ search }) => search, + loader: ({ deps }) => createHeadLoaderData('articles', 'list', deps), + head: ({ loaderData }) => createArticlesHead(loaderData!), + component: ArticlesPage, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.products.$productId.tsx b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.products.$productId.tsx new file mode 100644 index 0000000000..687bd85fa3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.products.$productId.tsx @@ -0,0 +1,30 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { createHeadLoaderData, createProductHead } from '../../../shared.ts' +import { Route as headRoute } from './head' + +const ProductPage = Vue.defineComponent({ + setup() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return () => ( +
+ ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'products/$productId', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('product', params.productId, deps), + head: ({ params, loaderData }) => + createProductHead(params.productId, loaderData!), + component: ProductPage, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.settings.tsx b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.settings.tsx new file mode 100644 index 0000000000..4c7679bd97 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.settings.tsx @@ -0,0 +1,29 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { createHeadLoaderData, createSettingsHead } from '../../../shared.ts' +import { Route as headRoute } from './head' + +const SettingsPage = Vue.defineComponent({ + setup() { + const params = Route.useParams() + const loaderData = Route.useLoaderData() + + return () => ( +
+ ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => headRoute, + path: 'settings/{-$tab}', + loaderDeps: ({ search }) => search, + loader: ({ params, deps }) => + createHeadLoaderData('settings', params.tab ?? 'general', deps), + head: ({ params, loaderData }) => createSettingsHead(params.tab, loaderData!), + component: SettingsPage, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.tsx b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.tsx new file mode 100644 index 0000000000..7b960a0859 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/src/routes/head.tsx @@ -0,0 +1,28 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { createHeadLoaderData, createHeadSectionHead } from '../../../shared.ts' +import { Route as rootRoute } from './__root' + +const HeadLayout = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( +
+ +
+ ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/head', + loaderDeps: ({ search }) => search, + loader: ({ deps }) => createHeadLoaderData('section', 'root', deps), + head: ({ loaderData }) => createHeadSectionHead(loaderData!), + component: HeadLayout, +}) diff --git a/benchmarks/client-nav/scenarios/head-management/vue/tsconfig.json b/benchmarks/client-nav/scenarios/head-management/vue/tsconfig.json new file mode 100644 index 0000000000..5b79f21361 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "vue", + "allowImportingTsExtensions": true, + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/head-management/vue/vite.config.ts b/benchmarks/client-nav/scenarios/head-management/vue/vite.config.ts new file mode 100644 index 0000000000..60de7d0911 --- /dev/null +++ b/benchmarks/client-nav/scenarios/head-management/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav head-management (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/project.json b/benchmarks/client-nav/scenarios/history-events-blockers/react/project.json new file mode 100644 index 0000000000..2c9a875102 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-history-events-blockers-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/setup.ts b/benchmarks/client-nav/scenarios/history-events-blockers/react/setup.ts new file mode 100644 index 0000000000..12a7db9b0c --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createHistoryEventsBlockersWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { historyEventsBlockersRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createHistoryEventsBlockersWorkload( + 'react', + mountTestApp, + historyEventsBlockersRuntime, +) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/speed.bench.ts b/benchmarks/client-nav/scenarios/history-events-blockers/react/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/speed.flame.ts b/benchmarks/client-nav/scenarios/history-events-blockers/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/app.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/app.tsx new file mode 100644 index 0000000000..dbc8274d47 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/app.tsx @@ -0,0 +1,24 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export { historyEventsBlockersRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routeTree.tsx new file mode 100644 index 0000000000..2fbd160ea4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routeTree.tsx @@ -0,0 +1,9 @@ +import { rootRoute } from './routes/__root' +import { doneRoute } from './routes/history.done' +import { formRoute } from './routes/history.form.$formId' +import { reviewRoute } from './routes/history.review.$reviewId' +import { historyRoute } from './routes/history' + +export const routeTree = rootRoute.addChildren([ + historyRoute.addChildren([formRoute, reviewRoute, doneRoute]), +]) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/router.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/router.tsx new file mode 100644 index 0000000000..0a9f317c6d --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/router.tsx @@ -0,0 +1,23 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { + historyEventsBlockersHomePath, + historyEventsBlockersRouterPendingMs, +} from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [historyEventsBlockersHomePath], + }), + defaultPendingMs: historyEventsBlockersRouterPendingMs, + defaultPendingMinMs: historyEventsBlockersRouterPendingMs, + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/__root.tsx new file mode 100644 index 0000000000..edc04c397b --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.done.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.done.tsx new file mode 100644 index 0000000000..56a5ec4fc4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.done.tsx @@ -0,0 +1,21 @@ +import { createRoute } from '@tanstack/react-router' +import { + historyEventsBlockersDonePath, + runHistoryEventsBlockersComputation, +} from '../../../shared.ts' +import { pathSeed } from '../runtime' +import { historyRoute } from './history' + +export const doneRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'done', + component: DonePage, +}) + +function DonePage() { + void runHistoryEventsBlockersComputation( + pathSeed(historyEventsBlockersDonePath), + ) + + return
+} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.form.$formId.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.form.$formId.tsx new file mode 100644 index 0000000000..a19bbd7ded --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.form.$formId.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import { createRoute, useBlocker } from '@tanstack/react-router' +import { + createHistoryEventsBlockerOptions, + runHistoryEventsBlockersComputation, +} from '../../../shared.ts' +import { + historyEventsBlockersRuntime, + pathSeed, + shouldBlockHistoryNavigation, +} from '../runtime' +import { historyRoute } from './history' + +export const formRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'form/$formId', + component: FormPage, +}) + +function FormPage() { + const params = formRoute.useParams() + const resolver = useBlocker( + createHistoryEventsBlockerOptions(shouldBlockHistoryNavigation), + ) + + React.useEffect(() => { + historyEventsBlockersRuntime.observeResolver(resolver) + }, [resolver]) + + void runHistoryEventsBlockersComputation(pathSeed(params.formId)) + + return ( +
+ {params.formId} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.review.$reviewId.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.review.$reviewId.tsx new file mode 100644 index 0000000000..9f85673500 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.review.$reviewId.tsx @@ -0,0 +1,24 @@ +import { createRoute } from '@tanstack/react-router' +import { runHistoryEventsBlockersComputation } from '../../../shared.ts' +import { pathSeed } from '../runtime' +import { historyRoute } from './history' + +export const reviewRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'review/$reviewId', + component: ReviewPage, +}) + +function ReviewPage() { + const params = reviewRoute.useParams() + void runHistoryEventsBlockersComputation(pathSeed(params.reviewId)) + + return ( +
+ {params.reviewId} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.tsx new file mode 100644 index 0000000000..3c6df99c57 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/routes/history.tsx @@ -0,0 +1,46 @@ +import { + Outlet, + createRoute, + useCanGoBack, + useRouterState, +} from '@tanstack/react-router' +import { useEffect } from 'react' +import { + historyEventsBlockersHomePath, + historyEventsBlockersScenarioSlug, +} from '../../../shared.ts' +import { historyEventsBlockersRuntime } from '../runtime' +import { rootRoute } from './__root' + +export const historyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/history', + component: HistoryLayout, +}) + +function HistoryLayout() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + + return ( + <> +
+ + {pathname === historyEventsBlockersHomePath ? ( +
+ ) : null} + + + ) +} + +function CanGoBackProbe() { + const canGoBack = useCanGoBack() + + useEffect(() => { + historyEventsBlockersRuntime.recordCanGoBack(canGoBack) + }, [canGoBack]) + + return +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/src/runtime.ts b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/runtime.ts new file mode 100644 index 0000000000..911e633c2a --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/src/runtime.ts @@ -0,0 +1,13 @@ +import { + createHistoryEventsBlockersRuntime, + historyEventsBlockersRouteSeed, + type HistoryBlockerArgs, +} from '../../shared.ts' + +export const historyEventsBlockersRuntime = createHistoryEventsBlockersRuntime() + +export function shouldBlockHistoryNavigation(args: HistoryBlockerArgs) { + return historyEventsBlockersRuntime.shouldBlock(args) +} + +export const pathSeed = historyEventsBlockersRouteSeed diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/tsconfig.json b/benchmarks/client-nav/scenarios/history-events-blockers/react/tsconfig.json new file mode 100644 index 0000000000..e5056ec745 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/react/vite.config.ts b/benchmarks/client-nav/scenarios/history-events-blockers/react/vite.config.ts new file mode 100644 index 0000000000..1ec079a651 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav history-events-blockers (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/shared.ts b/benchmarks/client-nav/scenarios/history-events-blockers/shared.ts new file mode 100644 index 0000000000..ed1f0cd4c5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/shared.ts @@ -0,0 +1,760 @@ +import type { RouterEvents } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' + +export type HistoryEventType = + | 'onBeforeNavigate' + | 'onBeforeLoad' + | 'onLoad' + | 'onBeforeRouteMount' + | 'onResolved' + | 'onRendered' + +export interface HistoryBlockerLocation { + pathname: string + routeId: string +} + +export interface HistoryBlockerArgs { + current: HistoryBlockerLocation + next: HistoryBlockerLocation + action: string +} + +export type HistoryBlockerResolver = + | { + status: 'blocked' + current: HistoryBlockerLocation + next: HistoryBlockerLocation + action: string + proceed: () => void + reset: () => void + } + | { + status: 'idle' + current: undefined + next: undefined + action: undefined + proceed: undefined + reset: undefined + } + +export interface HistoryEventsBlockersRuntime { + allowActiveBlocker: () => void + observeResolver: (resolver: HistoryBlockerResolver | undefined) => void + recordCanGoBack: (canGoBack: boolean) => void + recordEvent: ( + eventType: TEventType, + slot: number, + event: RouterEvents[TEventType], + ) => void + recordIgnoredNavigation: () => void + rejectActiveBlocker: () => void + reset: () => void + resetEventCounters: () => void + setBlockerMode: (mode: HistoryBlockerMode) => void + shouldBlock: (args: HistoryBlockerArgs) => boolean + snapshot: () => HistoryEventsBlockersSnapshot +} + +export type HistoryBlockerMode = 'disabled' | 'reject' | 'allow' + +export interface HistoryEventsBlockersSnapshot { + blockers: HistoryBlockerCounters + canGoBackReads: number + canGoBackTrueReads: number + eventChecksum: number + eventOrder: Array + events: Record + activeBlocker: ActiveHistoryBlockerSnapshot | undefined +} + +interface HistoryBlockerCounters { + attempts: number + ignored: number + rejected: number + allowed: number + resolverBlocked: number +} + +interface ActiveHistoryBlockerSnapshot { + action: string + currentPathname: string + nextPathname: string +} + +interface ActiveHistoryBlocker extends ActiveHistoryBlockerSnapshot { + proceed: () => void + reset: () => void +} + +interface HistoryEventsBlockersInput { + rejectedFormId: string + rejectedReviewId: string + allowedFormId: string + allowedReviewId: string +} + +type NavigationSettlement = + | { + status: 'fulfilled' + value: void + } + | { + status: 'rejected' + reason: unknown + } + +export const historyEventsBlockersScenarioSlug = 'history-events-blockers' +export const historyEventsBlockersHomePath = '/history' +export const historyEventsBlockersDonePath = '/history/done' +export const historyEventsBlockersRouterPendingMs = 0 + +const eventTypes = [ + 'onBeforeNavigate', + 'onBeforeLoad', + 'onLoad', + 'onBeforeRouteMount', + 'onResolved', + 'onRendered', +] as const satisfies ReadonlyArray + +const eventSubscriberSlots = [0, 1] as const +const random = createDeterministicRandom(16_016) + +const historyEventsBlockersInputs = Array.from({ length: 2 }, (_, index) => + createHistoryEventsBlockersInput(index), +) + +const historyEventsBlockersSanityInput = + createHistoryEventsBlockersInput(10_001) + +export function createHistoryEventsBlockersRuntime(): HistoryEventsBlockersRuntime { + let blockerMode: HistoryBlockerMode = 'disabled' + let activeBlocker: ActiveHistoryBlocker | undefined = undefined + let blockers = createBlockerCounters() + let canGoBackReads = 0 + let canGoBackTrueReads = 0 + let eventChecksum = 0 + let eventOrder: Array = [] + let events = createEventCounters() + + function resetActiveBlocker() { + const blocker = activeBlocker + activeBlocker = undefined + blocker?.reset() + } + + return { + allowActiveBlocker() { + const blocker = activeBlocker + + if (!blocker) { + throw new Error('No active history blocker to proceed') + } + + activeBlocker = undefined + blockers.allowed += 1 + blocker.proceed() + }, + observeResolver(resolver) { + if (!resolver || resolver.status === 'idle') { + return + } + + if ( + activeBlocker?.proceed === resolver.proceed && + activeBlocker.reset === resolver.reset + ) { + return + } + + activeBlocker = { + action: resolver.action, + currentPathname: resolver.current.pathname, + nextPathname: resolver.next.pathname, + proceed: resolver.proceed, + reset: resolver.reset, + } + blockers.resolverBlocked += 1 + }, + recordCanGoBack(canGoBack) { + canGoBackReads += 1 + + if (canGoBack) { + canGoBackTrueReads += 1 + } + + eventChecksum = runHistoryEventsBlockersComputation( + eventChecksum + (canGoBack ? 17 : 3), + ) + }, + recordEvent(eventType, slot, event) { + events[eventType] += 1 + eventOrder.push(eventType) + + if (eventOrder.length > 96) { + eventOrder = eventOrder.slice(-48) + } + + eventChecksum = runHistoryEventsBlockersComputation( + eventChecksum + + pathSeed(event.toLocation.pathname) + + eventTypes.indexOf(eventType) * 97 + + slot * 13 + + (event.pathChanged ? 5 : 0) + + (event.hrefChanged ? 7 : 0), + ) + }, + recordIgnoredNavigation() { + blockers.ignored += 1 + }, + rejectActiveBlocker() { + const blocker = activeBlocker + + if (!blocker) { + throw new Error('No active history blocker to reset') + } + + activeBlocker = undefined + blockers.rejected += 1 + blocker.reset() + }, + reset() { + resetActiveBlocker() + blockerMode = 'disabled' + blockers = createBlockerCounters() + canGoBackReads = 0 + canGoBackTrueReads = 0 + eventChecksum = 0 + eventOrder = [] + events = createEventCounters() + }, + resetEventCounters() { + eventChecksum = 0 + eventOrder = [] + events = createEventCounters() + }, + setBlockerMode(mode) { + blockerMode = mode + }, + shouldBlock(args) { + if (blockerMode === 'disabled') { + return false + } + + if (!isFormPath(args.current.pathname)) { + return false + } + + if (!isReviewPath(args.next.pathname)) { + return false + } + + blockers.attempts += 1 + return true + }, + snapshot() { + return { + blockers: { ...blockers }, + canGoBackReads, + canGoBackTrueReads, + eventChecksum, + eventOrder: [...eventOrder], + events: { ...events }, + activeBlocker: activeBlocker + ? { + action: activeBlocker.action, + currentPathname: activeBlocker.currentPathname, + nextPathname: activeBlocker.nextPathname, + } + : undefined, + } + }, + } +} + +export function createHistoryEventsBlockerOptions( + shouldBlockFn: (args: HistoryBlockerArgs) => boolean, +) { + return { + shouldBlockFn, + withResolver: true, + enableBeforeUnload: false, + } as const +} + +export function historyEventsBlockersRouteSeed(value: string) { + let seed = 0 + + for (let index = 0; index < value.length; index++) { + seed = (seed * 31 + value.charCodeAt(index)) >>> 0 + } + + return seed +} + +export function runHistoryEventsBlockersComputation(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 34; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function createHistoryEventsBlockersWorkload( + framework: Framework, + mountTestApp: MountTestApp, + runtime: HistoryEventsBlockersRuntime, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp }) + const detachedNavigations: Array> = [] + let eventUnsubscribers: Array<() => void> = [] + + function getPageMarker() { + return lifecycle + .getContainer() + .querySelector('[data-history-events-page]') + } + + function getCurrentPathname() { + return lifecycle.getRouter().state.location.pathname + } + + function assertRenderedPage( + page: 'dashboard' | 'form' | 'review' | 'done', + id?: string, + ) { + const marker = getPageMarker() + const actualPage = marker?.dataset.historyEventsPage + const actualId = marker?.dataset.historyEventsId + + if (actualPage !== page) { + throw new Error(`Expected history page ${page}, got ${actualPage}`) + } + + if (id !== undefined && actualId !== id) { + throw new Error(`Expected history id ${id}, got ${actualId}`) + } + } + + async function waitForPage( + page: 'dashboard' | 'form' | 'review' | 'done', + id?: string, + ) { + await lifecycle.waitForCounter( + () => { + try { + assertRenderedPage(page, id) + return 1 + } catch { + return 0 + } + }, + 1, + { label: `${page} history page marker` }, + ) + } + + function subscribeToRouterEvents() { + const router = lifecycle.getRouter() + eventUnsubscribers = [] + + for (const eventType of eventTypes) { + for (const slot of eventSubscriberSlots) { + eventUnsubscribers.push( + router.subscribe(eventType, (event) => { + runtime.recordEvent(eventType, slot, event) + }), + ) + } + } + } + + function unsubscribeFromRouterEvents() { + for (const unsubscribe of eventUnsubscribers.splice(0).reverse()) { + unsubscribe() + } + } + + async function navigatePushToForm(id: string) { + await lifecycle.navigate( + { + to: '/history/form/$formId', + params: { formId: id }, + }, + { wait: 'rendered', label: `push form ${id}` }, + ) + await waitForPage('form', id) + } + + async function navigateIgnoredToReview(id: string) { + runtime.recordIgnoredNavigation() + await lifecycle.navigate( + { + to: '/history/review/$reviewId', + params: { reviewId: id }, + ignoreBlocker: true, + }, + { wait: 'rendered', label: `ignored review ${id}` }, + ) + await waitForPage('review', id) + await drainDetachedNavigations(`ignored review ${id}`) + } + + async function navigatePushToSecondForm(id: string) { + await lifecycle.navigate( + { + to: '/history/form/$formId', + params: { formId: id }, + }, + { wait: 'rendered', label: `push second form ${id}` }, + ) + await waitForPage('form', id) + } + + async function navigateAllowedToReview(id: string) { + const before = runtime.snapshot().blockers.resolverBlocked + runtime.setBlockerMode('allow') + + await lifecycle.waitForRender( + async () => { + const navigation = lifecycle.getRouter().navigate({ + to: '/history/review/$reviewId', + params: { reviewId: id }, + }) + + await waitForBlocker(before, `/history/review/${id}`) + runtime.allowActiveBlocker() + await navigation + }, + { label: `allowed blocker review ${id}` }, + ) + + runtime.setBlockerMode('disabled') + await waitForPage('review', id) + await drainDetachedNavigations(`allowed review ${id}`) + } + + async function rejectReviewNavigation(formId: string, reviewId: string) { + const before = runtime.snapshot() + runtime.setBlockerMode('reject') + + const navigation = lifecycle.getRouter().navigate({ + to: '/history/review/$reviewId', + params: { reviewId }, + }) + detachedNavigations.push(captureNavigation(navigation)) + + await waitForBlocker( + before.blockers.resolverBlocked, + `/history/review/${reviewId}`, + ) + runtime.rejectActiveBlocker() + runtime.setBlockerMode('disabled') + await lifecycle.waitForCounter( + () => runtime.snapshot().blockers.rejected, + before.blockers.rejected + 1, + { label: `rejected blocker ${reviewId}` }, + ) + await lifecycle.waitForPromise(Promise.resolve(), { + label: `rejected blocker microtask ${reviewId}`, + }) + + if (getCurrentPathname() !== `/history/form/${formId}`) { + throw new Error( + `Rejected blocker left ${getCurrentPathname()} instead of /history/form/${formId}`, + ) + } + + await waitForPage('form', formId) + } + + async function goBackToForm(id: string) { + await lifecycle.waitForRender( + () => { + lifecycle.getRouter().history.back() + }, + { label: `history back to form ${id}` }, + ) + await waitForPage('form', id) + } + + async function goForwardToReview(id: string) { + await lifecycle.waitForRender( + () => { + lifecycle.getRouter().history.forward() + }, + { label: `history forward to review ${id}` }, + ) + await waitForPage('review', id) + } + + async function replaceDone() { + await lifecycle.navigate( + { + to: historyEventsBlockersDonePath, + replace: true, + }, + { wait: 'rendered', label: 'replace history done' }, + ) + await waitForPage('done') + } + + async function returnHomeIfNeeded() { + const router = lifecycle.getRouter() + const index = router.history.location.state.__TSR_index + + if (router.state.location.pathname === historyEventsBlockersHomePath) { + return + } + + if (index <= 0) { + await lifecycle.navigate( + { + to: historyEventsBlockersHomePath, + replace: true, + ignoreBlocker: true, + }, + { wait: 'rendered', label: 'replace history home' }, + ) + await waitForPage('dashboard') + return + } + + await lifecycle.waitForRender( + () => { + router.history.go(-index, { ignoreBlocker: true }) + }, + { label: 'history return home' }, + ) + await waitForPage('dashboard') + } + + async function waitForBlocker( + expectedResolverBlocked: number, + expectedPath: string, + ) { + await lifecycle.waitForCounter( + () => runtime.snapshot().blockers.resolverBlocked, + expectedResolverBlocked + 1, + { label: `blocker resolver ${expectedPath}` }, + ) + + const blocker = runtime.snapshot().activeBlocker + + if (!blocker) { + throw new Error(`No active blocker for ${expectedPath}`) + } + + if (blocker.nextPathname !== expectedPath) { + throw new Error( + `Expected blocker target ${expectedPath}, got ${blocker.nextPathname}`, + ) + } + } + + async function drainDetachedNavigations(label: string) { + while (detachedNavigations.length > 0) { + await lifecycle.waitForPromise(detachedNavigations.shift()!, { + label: `${label} detached navigation`, + }) + } + } + + async function runHistoryGroup( + input: HistoryEventsBlockersInput, + assertEventCounters: boolean, + ) { + if (assertEventCounters) { + runtime.resetEventCounters() + } + + await navigatePushToForm(input.rejectedFormId) + + if (assertEventCounters) { + assertSingleNavigationEventOrder(runtime.snapshot()) + } + + await rejectReviewNavigation(input.rejectedFormId, input.rejectedReviewId) + await navigateIgnoredToReview(input.rejectedReviewId) + await navigatePushToSecondForm(input.allowedFormId) + await navigateAllowedToReview(input.allowedReviewId) + await goBackToForm(input.allowedFormId) + await goForwardToReview(input.allowedReviewId) + await replaceDone() + } + + async function before() { + runtime.reset() + detachedNavigations.length = 0 + unsubscribeFromRouterEvents() + await lifecycle.before() + subscribeToRouterEvents() + runtime.resetEventCounters() + await waitForPage('dashboard') + } + + async function after() { + runtime.reset() + detachedNavigations.length = 0 + unsubscribeFromRouterEvents() + await lifecycle.after() + runtime.reset() + } + + return { + name: `client history events blockers loop (${framework})`, + before, + async run() { + for (const input of historyEventsBlockersInputs) { + await returnHomeIfNeeded() + await runHistoryGroup(input, false) + } + }, + async sanity() { + await before() + + try { + await runHistoryGroup(historyEventsBlockersSanityInput, true) + assertRenderedPage('done') + + const snapshot = runtime.snapshot() + + if (snapshot.blockers.rejected < 1) { + throw new Error('History blocker sanity did not reject a navigation') + } + + if (snapshot.blockers.allowed < 1) { + throw new Error('History blocker sanity did not allow a navigation') + } + + if ( + snapshot.canGoBackReads === 0 || + snapshot.canGoBackTrueReads === 0 + ) { + throw new Error( + 'useCanGoBack subscriber did not observe stack changes', + ) + } + } finally { + await after() + } + }, + after, + } +} + +function createHistoryEventsBlockersInput( + index: number, +): HistoryEventsBlockersInput { + return { + rejectedFormId: token('reject-form', index), + rejectedReviewId: token('reject-review', index), + allowedFormId: token('allow-form', index), + allowedReviewId: token('allow-review', index), + } +} + +function token(prefix: string, index: number) { + return `${prefix}-${index}-${randomSegment(random)}` +} + +function createBlockerCounters(): HistoryBlockerCounters { + return { + attempts: 0, + ignored: 0, + rejected: 0, + allowed: 0, + resolverBlocked: 0, + } +} + +function createEventCounters(): Record { + return { + onBeforeNavigate: 0, + onBeforeLoad: 0, + onLoad: 0, + onBeforeRouteMount: 0, + onResolved: 0, + onRendered: 0, + } +} + +function isFormPath(pathname: string) { + return pathname.startsWith('/history/form/') +} + +function isReviewPath(pathname: string) { + return pathname.startsWith('/history/review/') +} + +function pathSeed(pathname: string) { + let seed = 0 + + for (let index = 0; index < pathname.length; index++) { + seed = (seed * 33 + pathname.charCodeAt(index)) >>> 0 + } + + return seed +} + +function captureNavigation( + value: Promise, +): Promise { + return value + .then( + (result): NavigationSettlement => ({ + status: 'fulfilled', + value: result, + }), + ) + .catch( + (reason: unknown): NavigationSettlement => ({ + status: 'rejected', + reason, + }), + ) +} + +function assertSingleNavigationEventOrder( + snapshot: HistoryEventsBlockersSnapshot, +) { + for (const eventType of eventTypes) { + if (snapshot.events[eventType] < eventSubscriberSlots.length) { + throw new Error(`Expected ${eventType} subscribers to run`) + } + } + + assertEventPrecedes(snapshot.eventOrder, 'onBeforeNavigate', 'onBeforeLoad') + assertEventPrecedes(snapshot.eventOrder, 'onBeforeLoad', 'onLoad') + assertEventPrecedes(snapshot.eventOrder, 'onBeforeRouteMount', 'onResolved') + assertEventPrecedes(snapshot.eventOrder, 'onResolved', 'onRendered') +} + +function assertEventPrecedes( + eventOrder: Array, + before: HistoryEventType, + after: HistoryEventType, +) { + const beforeIndex = eventOrder.indexOf(before) + const afterIndex = eventOrder.indexOf(after) + + if (beforeIndex === -1 || afterIndex === -1 || beforeIndex >= afterIndex) { + throw new Error( + `Expected ${before} before ${after}, got ${eventOrder.join(',')}`, + ) + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/project.json b/benchmarks/client-nav/scenarios/history-events-blockers/solid/project.json new file mode 100644 index 0000000000..def308b386 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-history-events-blockers-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/setup.ts b/benchmarks/client-nav/scenarios/history-events-blockers/solid/setup.ts new file mode 100644 index 0000000000..c73c781bff --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createHistoryEventsBlockersWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { historyEventsBlockersRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createHistoryEventsBlockersWorkload( + 'solid', + mountTestApp, + historyEventsBlockersRuntime, +) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/history-events-blockers/solid/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/history-events-blockers/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/app.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/app.tsx new file mode 100644 index 0000000000..869af3082d --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/app.tsx @@ -0,0 +1,23 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { getRouter } from './router' + +export { historyEventsBlockersRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routeTree.tsx new file mode 100644 index 0000000000..2fbd160ea4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routeTree.tsx @@ -0,0 +1,9 @@ +import { rootRoute } from './routes/__root' +import { doneRoute } from './routes/history.done' +import { formRoute } from './routes/history.form.$formId' +import { reviewRoute } from './routes/history.review.$reviewId' +import { historyRoute } from './routes/history' + +export const routeTree = rootRoute.addChildren([ + historyRoute.addChildren([formRoute, reviewRoute, doneRoute]), +]) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/router.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/router.tsx new file mode 100644 index 0000000000..d27386c7b4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/router.tsx @@ -0,0 +1,23 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { + historyEventsBlockersHomePath, + historyEventsBlockersRouterPendingMs, +} from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [historyEventsBlockersHomePath], + }), + defaultPendingMs: historyEventsBlockersRouterPendingMs, + defaultPendingMinMs: historyEventsBlockersRouterPendingMs, + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..1e9054643e --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.done.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.done.tsx new file mode 100644 index 0000000000..dffd43a90f --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.done.tsx @@ -0,0 +1,21 @@ +import { createRoute } from '@tanstack/solid-router' +import { + historyEventsBlockersDonePath, + runHistoryEventsBlockersComputation, +} from '../../../shared.ts' +import { pathSeed } from '../runtime' +import { historyRoute } from './history' + +export const doneRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'done', + component: DonePage, +}) + +function DonePage() { + void runHistoryEventsBlockersComputation( + pathSeed(historyEventsBlockersDonePath), + ) + + return
+} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.form.$formId.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.form.$formId.tsx new file mode 100644 index 0000000000..4dfce0acc6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.form.$formId.tsx @@ -0,0 +1,42 @@ +import { createRenderEffect } from 'solid-js' +import { createRoute, useBlocker } from '@tanstack/solid-router' +import { + createHistoryEventsBlockerOptions, + runHistoryEventsBlockersComputation, +} from '../../../shared.ts' +import { + historyEventsBlockersRuntime, + pathSeed, + shouldBlockHistoryNavigation, +} from '../runtime' +import { historyRoute } from './history' + +export const formRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'form/$formId', + component: FormPage, +}) + +function FormPage() { + const params = formRoute.useParams() + const resolver = useBlocker( + createHistoryEventsBlockerOptions(shouldBlockHistoryNavigation), + ) + + createRenderEffect(() => { + historyEventsBlockersRuntime.observeResolver(resolver()) + }) + + createRenderEffect(() => { + void runHistoryEventsBlockersComputation(pathSeed(params().formId)) + }) + + return ( +
+ {params().formId} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.review.$reviewId.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.review.$reviewId.tsx new file mode 100644 index 0000000000..c67b8f3174 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.review.$reviewId.tsx @@ -0,0 +1,28 @@ +import { createRenderEffect } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { runHistoryEventsBlockersComputation } from '../../../shared.ts' +import { pathSeed } from '../runtime' +import { historyRoute } from './history' + +export const reviewRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'review/$reviewId', + component: ReviewPage, +}) + +function ReviewPage() { + const params = reviewRoute.useParams() + + createRenderEffect(() => { + void runHistoryEventsBlockersComputation(pathSeed(params().reviewId)) + }) + + return ( +
+ {params().reviewId} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.tsx new file mode 100644 index 0000000000..c632d6c4d0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/routes/history.tsx @@ -0,0 +1,48 @@ +import { Show, createRenderEffect } from 'solid-js' +import { + Outlet, + createRoute, + useCanGoBack, + useRouterState, +} from '@tanstack/solid-router' +import { + historyEventsBlockersHomePath, + historyEventsBlockersScenarioSlug, +} from '../../../shared.ts' +import { historyEventsBlockersRuntime } from '../runtime' +import { rootRoute } from './__root' + +export const historyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/history', + component: HistoryLayout, +}) + +function HistoryLayout() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + + return ( + <> +
+ + +
+ + + + ) +} + +function CanGoBackProbe() { + const canGoBack = useCanGoBack() + + createRenderEffect(() => { + historyEventsBlockersRuntime.recordCanGoBack(canGoBack()) + }) + + return ( + + ) +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/runtime.ts b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/runtime.ts new file mode 100644 index 0000000000..911e633c2a --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/src/runtime.ts @@ -0,0 +1,13 @@ +import { + createHistoryEventsBlockersRuntime, + historyEventsBlockersRouteSeed, + type HistoryBlockerArgs, +} from '../../shared.ts' + +export const historyEventsBlockersRuntime = createHistoryEventsBlockersRuntime() + +export function shouldBlockHistoryNavigation(args: HistoryBlockerArgs) { + return historyEventsBlockersRuntime.shouldBlock(args) +} + +export const pathSeed = historyEventsBlockersRouteSeed diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/tsconfig.json b/benchmarks/client-nav/scenarios/history-events-blockers/solid/tsconfig.json new file mode 100644 index 0000000000..b549cd9fe8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/solid/vite.config.ts b/benchmarks/client-nav/scenarios/history-events-blockers/solid/vite.config.ts new file mode 100644 index 0000000000..b9a9b7f37f --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/solid/vite.config.ts @@ -0,0 +1,42 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav history-events-blockers (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/project.json b/benchmarks/client-nav/scenarios/history-events-blockers/vue/project.json new file mode 100644 index 0000000000..12ce4bbd11 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-history-events-blockers-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/setup.ts b/benchmarks/client-nav/scenarios/history-events-blockers/vue/setup.ts new file mode 100644 index 0000000000..12cfbb1291 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createHistoryEventsBlockersWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { historyEventsBlockersRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createHistoryEventsBlockersWorkload( + 'vue', + mountTestApp, + historyEventsBlockersRuntime, +) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/history-events-blockers/vue/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/history-events-blockers/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/app.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/app.tsx new file mode 100644 index 0000000000..2e2a0eaa86 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/app.tsx @@ -0,0 +1,27 @@ +import * as Vue from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { getRouter } from './router' + +export { historyEventsBlockersRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = Vue.createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routeTree.tsx new file mode 100644 index 0000000000..2fbd160ea4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routeTree.tsx @@ -0,0 +1,9 @@ +import { rootRoute } from './routes/__root' +import { doneRoute } from './routes/history.done' +import { formRoute } from './routes/history.form.$formId' +import { reviewRoute } from './routes/history.review.$reviewId' +import { historyRoute } from './routes/history' + +export const routeTree = rootRoute.addChildren([ + historyRoute.addChildren([formRoute, reviewRoute, doneRoute]), +]) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/router.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/router.tsx new file mode 100644 index 0000000000..e7b3dcac26 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/router.tsx @@ -0,0 +1,23 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { + historyEventsBlockersHomePath, + historyEventsBlockersRouterPendingMs, +} from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [historyEventsBlockersHomePath], + }), + defaultPendingMs: historyEventsBlockersRouterPendingMs, + defaultPendingMinMs: historyEventsBlockersRouterPendingMs, + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..1904cf90ef --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/__root.tsx @@ -0,0 +1,12 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +const Root = Vue.defineComponent({ + setup() { + return () => + }, +}) + +export const rootRoute = createRootRoute({ + component: Root, +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.done.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.done.tsx new file mode 100644 index 0000000000..62f1a0135d --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.done.tsx @@ -0,0 +1,26 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + historyEventsBlockersDonePath, + runHistoryEventsBlockersComputation, +} from '../../../shared.ts' +import { pathSeed } from '../runtime' +import { historyRoute } from './history' + +const DonePage = Vue.defineComponent({ + setup() { + return () => { + void runHistoryEventsBlockersComputation( + pathSeed(historyEventsBlockersDonePath), + ) + + return
+ } + }, +}) + +export const doneRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'done', + component: DonePage, +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.form.$formId.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.form.$formId.tsx new file mode 100644 index 0000000000..07ec16b384 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.form.$formId.tsx @@ -0,0 +1,44 @@ +import * as Vue from 'vue' +import { createRoute, useBlocker } from '@tanstack/vue-router' +import { + createHistoryEventsBlockerOptions, + runHistoryEventsBlockersComputation, +} from '../../../shared.ts' +import { + historyEventsBlockersRuntime, + pathSeed, + shouldBlockHistoryNavigation, +} from '../runtime' +import { historyRoute } from './history' + +const FormPage = Vue.defineComponent({ + setup() { + const params = formRoute.useParams() + const resolver = useBlocker( + createHistoryEventsBlockerOptions(shouldBlockHistoryNavigation), + ) + + Vue.watchEffect(() => { + historyEventsBlockersRuntime.observeResolver(resolver.value) + }) + + return () => { + void runHistoryEventsBlockersComputation(pathSeed(params.value.formId)) + + return ( +
+ {params.value.formId} +
+ ) + } + }, +}) + +export const formRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'form/$formId', + component: FormPage, +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.review.$reviewId.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.review.$reviewId.tsx new file mode 100644 index 0000000000..47834bf644 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.review.$reviewId.tsx @@ -0,0 +1,30 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { runHistoryEventsBlockersComputation } from '../../../shared.ts' +import { pathSeed } from '../runtime' +import { historyRoute } from './history' + +const ReviewPage = Vue.defineComponent({ + setup() { + const params = reviewRoute.useParams() + + return () => { + void runHistoryEventsBlockersComputation(pathSeed(params.value.reviewId)) + + return ( +
+ {params.value.reviewId} +
+ ) + } + }, +}) + +export const reviewRoute = createRoute({ + getParentRoute: () => historyRoute, + path: 'review/$reviewId', + component: ReviewPage, +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.tsx b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.tsx new file mode 100644 index 0000000000..afcce38d17 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/routes/history.tsx @@ -0,0 +1,55 @@ +import * as Vue from 'vue' +import type { AnyRouter } from '@tanstack/router-core' +import { + Outlet, + createRoute, + useCanGoBack, + useRouterState, +} from '@tanstack/vue-router' +import { + historyEventsBlockersHomePath, + historyEventsBlockersScenarioSlug, +} from '../../../shared.ts' +import { historyEventsBlockersRuntime } from '../runtime' +import { rootRoute } from './__root' + +const CanGoBackProbe = Vue.defineComponent({ + setup(): () => Vue.VNodeChild { + const canGoBack = useCanGoBack() + + Vue.watchEffect(() => { + historyEventsBlockersRuntime.recordCanGoBack(canGoBack.value) + }) + + return () => ( + + ) + }, +}) + +const HistoryLayout = Vue.defineComponent({ + setup(): () => Vue.VNodeChild { + const pathname: Vue.Ref = useRouterState({ + select: (state) => state.location.pathname, + }) + + return () => ( + <> +
+ + {pathname.value === historyEventsBlockersHomePath ? ( +
+ ) : null} + + + ) + }, +}) + +export const historyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/history', + component: HistoryLayout, +}) diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/runtime.ts b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/runtime.ts new file mode 100644 index 0000000000..911e633c2a --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/src/runtime.ts @@ -0,0 +1,13 @@ +import { + createHistoryEventsBlockersRuntime, + historyEventsBlockersRouteSeed, + type HistoryBlockerArgs, +} from '../../shared.ts' + +export const historyEventsBlockersRuntime = createHistoryEventsBlockersRuntime() + +export function shouldBlockHistoryNavigation(args: HistoryBlockerArgs) { + return historyEventsBlockersRuntime.shouldBlock(args) +} + +export const pathSeed = historyEventsBlockersRouteSeed diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/tsconfig.json b/benchmarks/client-nav/scenarios/history-events-blockers/vue/tsconfig.json new file mode 100644 index 0000000000..24bdb3e3cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/history-events-blockers/vue/vite.config.ts b/benchmarks/client-nav/scenarios/history-events-blockers/vue/vite.config.ts new file mode 100644 index 0000000000..b5bb4693e8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/history-events-blockers/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav history-events-blockers (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/flame-jsdom.ts b/benchmarks/client-nav/scenarios/hydration-resume/flame-jsdom.ts new file mode 100644 index 0000000000..49018a2716 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/flame-jsdom.ts @@ -0,0 +1,27 @@ +import { window } from '../../jsdom.ts' + +export function installHydrationResumeFlameGlobals() { + const hadScrollTo = 'scrollTo' in globalThis + const previousScrollTo = globalThis.scrollTo + + Object.defineProperty(globalThis, 'scrollTo', { + configurable: true, + value: window.scrollTo.bind(window), + writable: true, + }) + + return () => { + if (hadScrollTo) { + Object.defineProperty(globalThis, 'scrollTo', { + configurable: true, + value: previousScrollTo, + writable: true, + }) + return + } + + Reflect.deleteProperty(globalThis, 'scrollTo') + } +} + +export { window } diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/project.json b/benchmarks/client-nav/scenarios/hydration-resume/react/project.json new file mode 100644 index 0000000000..cfeb4689b7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-hydration-resume-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/setup.ts b/benchmarks/client-nav/scenarios/hydration-resume/react/setup.ts new file mode 100644 index 0000000000..6caf76f03a --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createHydrationResumeWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const appModule = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createHydrationResumeWorkload('react', appModule) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/speed.bench.ts b/benchmarks/client-nav/scenarios/hydration-resume/react/speed.bench.ts new file mode 100644 index 0000000000..8859cebd05 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav hydration-resume', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/speed.flame.ts b/benchmarks/client-nav/scenarios/hydration-resume/react/speed.flame.ts new file mode 100644 index 0000000000..fdc5a87f61 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/speed.flame.ts @@ -0,0 +1,19 @@ +import { installHydrationResumeFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installHydrationResumeFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/app.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/app.tsx new file mode 100644 index 0000000000..b87e07d7b4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/app.tsx @@ -0,0 +1,81 @@ +import { RouterProvider } from '@tanstack/react-router' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { createRoot } from 'react-dom/client' +import { + createStandaloneHydrationFixture, + seedHydrationResumeSsrGlobal, + type HydrationResumeFixture, +} from '../../shared.ts' +import { + createHydrationResumeRouter, + getHydrationResumeRouteIds, + getRouter, +} from './router' +import { hydrationResumeRuntime } from './runtime' + +export { hydrationResumeRuntime } from './runtime' + +export async function mountHydratedTestApp( + container: Element, + fixture: HydrationResumeFixture, +) { + hydrationResumeRuntime.startCycle(fixture) + + const router = createHydrationResumeRouter(fixture.href) + const cleanup = seedHydrationResumeSsrGlobal( + router, + getHydrationResumeRouteIds(), + hydrationResumeRuntime, + fixture, + ) + let didUnmount = false + let reactRoot: ReturnType | undefined = undefined + + try { + await hydrate(router) + window.$_TSR?.h() + reactRoot = createRoot(container) + reactRoot.render() + } catch (error) { + cleanup() + router.history.destroy() + hydrationResumeRuntime.clearCycle() + throw error + } + + return { + router, + cleanup, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot?.unmount() + }, + } +} + +export function mountTestApp(container: Element) { + const fixture = createStandaloneHydrationFixture() + hydrationResumeRuntime.startCycle(fixture) + + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/perf.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/perf.tsx new file mode 100644 index 0000000000..df72a4084b --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/perf.tsx @@ -0,0 +1,25 @@ +import { + createDeferredResolvedMarkerAttributes, + hydrationResumeSubscriberSlots, + runHydrationResumeComputation, + type HydrationResumeDeferredPayload, +} from '../../shared.ts' + +export const subscriberSlots = hydrationResumeSubscriberSlots + +export function PerfSubscriber({ seed }: { seed: number }) { + void runHydrationResumeComputation(seed) + return null +} + +export function DeferredResolved({ + payload, + source, +}: { + payload: HydrationResumeDeferredPayload + source: string +}) { + void runHydrationResumeComputation(payload.checksum) + + return
+} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routeTree.tsx new file mode 100644 index 0000000000..3ae7a64c11 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routeTree.tsx @@ -0,0 +1,25 @@ +import type { HydrationResumeRouteIds } from '../../shared.ts' +import { rootRoute } from './routes/__root' +import { dashboardRoute } from './routes/hydrate.dashboard' +import { teamRoute } from './routes/hydrate.dashboard.$teamId' +import { deferredRoute } from './routes/hydrate.deferred.$itemId' +import { liveRoute } from './routes/hydrate.live.$itemId' +import { hydrateRoute } from './routes/hydrate' + +export const routeTree = rootRoute.addChildren([ + hydrateRoute.addChildren([ + dashboardRoute.addChildren([teamRoute]), + deferredRoute, + liveRoute, + ]), +]) + +export function getHydrationResumeRouteIds(): HydrationResumeRouteIds { + return { + hydrate: hydrateRoute.id, + dashboard: dashboardRoute.id, + team: teamRoute.id, + deferred: deferredRoute.id, + live: liveRoute.id, + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/router.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/router.tsx new file mode 100644 index 0000000000..d82917ba50 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/router.tsx @@ -0,0 +1,33 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { + hydrationResumeRouterPendingMs, + hydrationResumeStandaloneInitialEntry, +} from '../../shared.ts' +import { routeTree, getHydrationResumeRouteIds } from './routeTree' +import { hydrationResumeRuntime } from './runtime' + +export { getHydrationResumeRouteIds } + +export function createHydrationResumeRouter(initialEntry: string) { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialEntry], + }), + routeTree, + defaultPendingMs: hydrationResumeRouterPendingMs, + defaultPendingMinMs: hydrationResumeRouterPendingMs, + hydrate: (dehydratedData: unknown) => { + hydrationResumeRuntime.recordCustomHydrate(dehydratedData) + }, + }) +} + +export function getRouter() { + return createHydrationResumeRouter(hydrationResumeStandaloneInitialEntry) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/__root.tsx new file mode 100644 index 0000000000..edc04c397b --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.dashboard.$teamId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.dashboard.$teamId.tsx new file mode 100644 index 0000000000..2d6da4f107 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.dashboard.$teamId.tsx @@ -0,0 +1,58 @@ +import { createRoute } from '@tanstack/react-router' +import { + buildTeamBeforeLoadContext, + buildTeamLoaderData, + createDashboardHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, + normalizeDashboardSearch, + pickDashboardLoaderDeps, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { getDashboardFixture, hydrationResumeRuntime } from '../runtime' +import { dashboardRoute } from './hydrate.dashboard' + +export const teamRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: '$teamId', + validateSearch: normalizeDashboardSearch, + loaderDeps: ({ search }) => pickDashboardLoaderDeps(search), + beforeLoad: () => { + const fixture = getDashboardFixture() + hydrationResumeRuntime.recordBeforeLoad('teamBeforeLoad') + return buildTeamBeforeLoadContext(fixture) + }, + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('team') + return buildTeamLoaderData(getDashboardFixture(), 'client', sequence) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: TeamPage, +}) + +function TeamPage() { + const params = teamRoute.useParams() + const search = teamRoute.useSearch() + const loaderData = teamRoute.useLoaderData() + const routeContext = teamRoute.useRouteContext() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.dashboard.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.dashboard.tsx new file mode 100644 index 0000000000..8c215d72c0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.dashboard.tsx @@ -0,0 +1,44 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { + buildDashboardBeforeLoadContext, + buildDashboardLoaderData, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { getDashboardFixture, hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +export const dashboardRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'dashboard', + beforeLoad: () => { + const fixture = getDashboardFixture() + hydrationResumeRuntime.recordBeforeLoad('dashboardBeforeLoad') + return buildDashboardBeforeLoadContext(fixture) + }, + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('dashboard') + return buildDashboardLoaderData(getDashboardFixture(), 'client', sequence) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: DashboardLayout, +}) + +function DashboardLayout() { + const loaderData = dashboardRoute.useLoaderData() + const routeContext = dashboardRoute.useRouteContext() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} + + + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.deferred.$itemId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.deferred.$itemId.tsx new file mode 100644 index 0000000000..fefb42402f --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.deferred.$itemId.tsx @@ -0,0 +1,62 @@ +import { Suspense } from 'react' +import { Await, createRoute } from '@tanstack/react-router' +import { + createDeferredHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, + type HydrationResumeDeferredPayload, +} from '../../../shared.ts' +import { DeferredResolved } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +export const deferredRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'deferred/$itemId', + loader: ({ params }) => { + const sequence = hydrationResumeRuntime.recordLoader('deferred') + return hydrationResumeRuntime.createClientDeferredLoaderData( + String(params.itemId), + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: DeferredPage, +}) + +function DeferredPage() { + const loaderData = deferredRoute.useLoaderData() + + return ( + <> +
+ + } + > + + {(payload) => ( + + )} + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.live.$itemId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.live.$itemId.tsx new file mode 100644 index 0000000000..e17526ef90 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.live.$itemId.tsx @@ -0,0 +1,45 @@ +import { createRoute } from '@tanstack/react-router' +import { + buildLiveLoaderData, + createLiveHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +export const liveRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'live/$itemId', + loader: ({ params }) => { + const sequence = hydrationResumeRuntime.recordLoader('live') + return buildLiveLoaderData( + hydrationResumeRuntime.getActiveFixture(), + String(params.itemId), + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: LivePage, +}) + +function LivePage() { + const params = liveRoute.useParams() + const loaderData = liveRoute.useLoaderData() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.tsx b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.tsx new file mode 100644 index 0000000000..96d7898ff3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/routes/hydrate.tsx @@ -0,0 +1,43 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { + buildHydrateLoaderData, + createHydrateSectionAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { rootRoute } from './__root' + +export const hydrateRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/hydrate', + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('hydrate') + return buildHydrateLoaderData( + hydrationResumeRuntime.getActiveFixture(), + 'client', + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: HydrateLayout, +}) + +function HydrateLayout() { + const loaderData = hydrateRoute.useLoaderData() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/src/runtime.ts b/benchmarks/client-nav/scenarios/hydration-resume/react/src/runtime.ts new file mode 100644 index 0000000000..532941b0c5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/src/runtime.ts @@ -0,0 +1,10 @@ +import { + createHydrationResumeRuntime, + getDashboardHydrationFixture, +} from '../../shared.ts' + +export const hydrationResumeRuntime = createHydrationResumeRuntime() + +export function getDashboardFixture() { + return getDashboardHydrationFixture(hydrationResumeRuntime) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/tsconfig.json b/benchmarks/client-nav/scenarios/hydration-resume/react/tsconfig.json new file mode 100644 index 0000000000..f2e7c1160a --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/react/vite.config.ts b/benchmarks/client-nav/scenarios/hydration-resume/react/vite.config.ts new file mode 100644 index 0000000000..7ff7c78152 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/react/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav hydration-resume (react)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/shared.ts b/benchmarks/client-nav/scenarios/hydration-resume/shared.ts new file mode 100644 index 0000000000..25f26e01c1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/shared.ts @@ -0,0 +1,704 @@ +import type { AnyRouter } from '@tanstack/router-core' +import type { + DehydratedMatch, + DehydratedRouter, + TsrSsrGlobal, +} from '@tanstack/router-core/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + $R?: Record + } +} + +export type HydrationResumeFramework = 'react' | 'solid' | 'vue' + +export type DashboardTab = 'summary' | 'metrics' + +export interface DashboardSearch { + tab: DashboardTab + cursor: string +} + +export type HydrationResumeFixture = + | { + kind: 'dashboard' + fixtureId: string + href: string + teamId: string + search: DashboardSearch + updatedAt: number + seed: number + } + | { + kind: 'deferred' + fixtureId: string + href: string + itemId: string + updatedAt: number + seed: number + } + +export interface HydrationResumeLoaderData { + route: 'hydrate' | 'dashboard' | 'team' | 'live' + source: 'ssr' | 'client' + fixtureId: string + sequence: number + checksum: number +} + +export interface HydrationResumeDeferredPayload { + itemId: string + value: string + checksum: number +} + +export interface HydrationResumeDeferredLoaderData { + route: 'deferred' + source: 'ssr' | 'client' + fixtureId: string + itemId: string + sequence: number + checksum: number + deferred: Promise +} + +export interface HydrationResumeRouteIds { + hydrate: string + dashboard: string + team: string + deferred: string + live: string +} + +export interface HydrationResumeCounters { + hydrate: number + dashboard: number + team: number + live: number + deferred: number + dashboardBeforeLoad: number + teamBeforeLoad: number + customHydrate: number + hydrationComplete: number + deferredResolved: number + cleanup: number +} + +export interface MountedHydrationResumeApp { + router: AnyRouter + cleanup: () => void + unmount: () => void +} + +export interface HydrationResumeAppModule { + hydrationResumeRuntime: HydrationResumeRuntime + mountHydratedTestApp: ( + container: Element, + fixture: HydrationResumeFixture, + ) => Promise +} + +type ControlledDeferred = { + resolve: (payload: HydrationResumeDeferredPayload) => void + resolved: boolean + promise: Promise +} + +const FIXTURE_COUNT = 8 +const FIXED_UPDATED_AT = 1_700_000_000_000 + +export const hydrationResumeRouteStaleTime = Infinity +export const hydrationResumeRouteGcTime = Infinity +export const hydrationResumeRouterPendingMs = 0 +export const hydrationResumeStandaloneInitialEntry = + '/hydrate/live/live-contract' +export const hydrationResumeSubscriberSlots = Array.from( + { length: 4 }, + (_, index) => index, +) + +function createEmptyCounters(): HydrationResumeCounters { + return { + hydrate: 0, + dashboard: 0, + team: 0, + live: 0, + deferred: 0, + dashboardBeforeLoad: 0, + teamBeforeLoad: 0, + customHydrate: 0, + hydrationComplete: 0, + deferredResolved: 0, + cleanup: 0, + } +} + +function dehydrateMatchId(id: string) { + return id.replaceAll('/', '\0') +} + +function checksumText(value: string, seed: number) { + let checksum = seed >>> 0 + + for (let index = 0; index < value.length; index++) { + checksum = (checksum * 33 + value.charCodeAt(index) + index) >>> 0 + } + + return checksum +} + +function createDashboardHref(teamId: string, search: DashboardSearch) { + return `/hydrate/dashboard/${teamId}?tab=${search.tab}&cursor=${search.cursor}` +} + +function createDeferredHref(itemId: string) { + return `/hydrate/deferred/${itemId}` +} + +function getFixtureSlot(index: number) { + return index % FIXTURE_COUNT +} + +export function createDashboardHydrationFixture( + index: number, +): Extract { + const slot = getFixtureSlot(index) + const tab: DashboardTab = slot % 2 === 0 ? 'summary' : 'metrics' + const teamId = `team-${slot}` + const search = { + tab, + cursor: `cursor-${slot}`, + } satisfies DashboardSearch + + return { + kind: 'dashboard', + fixtureId: `dashboard-${slot}`, + href: createDashboardHref(teamId, search), + teamId, + search, + updatedAt: FIXED_UPDATED_AT + slot * 100, + seed: 101 + slot * 17, + } +} + +export function createDeferredHydrationFixture( + index: number, +): Extract { + const slot = getFixtureSlot(index) + const itemId = `deferred-${slot}` + + return { + kind: 'deferred', + fixtureId: `deferred-${slot}`, + href: createDeferredHref(itemId), + itemId, + updatedAt: FIXED_UPDATED_AT + 1_000 + slot * 100, + seed: 211 + slot * 19, + } +} + +export function createLiveNavigationTarget(index: number) { + const slot = getFixtureSlot(index) + + return { + itemId: `live-${slot}`, + } +} + +export function createStandaloneHydrationFixture() { + return createDashboardHydrationFixture(0) +} + +export function normalizeDashboardSearch( + search: Record, +): DashboardSearch { + return { + tab: search.tab === 'metrics' ? 'metrics' : 'summary', + cursor: + typeof search.cursor === 'string' && search.cursor.length > 0 + ? search.cursor + : 'cursor-0', + } +} + +export function runHydrationResumeComputation(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 48; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function buildHydrateLoaderData( + fixture: HydrationResumeFixture, + source: 'ssr' | 'client', + sequence: number, +): HydrationResumeLoaderData { + return { + route: 'hydrate', + source, + fixtureId: fixture.fixtureId, + sequence, + checksum: checksumText(`${fixture.fixtureId}:hydrate`, fixture.seed), + } +} + +export function buildDashboardLoaderData( + fixture: Extract, + source: 'ssr' | 'client', + sequence: number, +): HydrationResumeLoaderData { + return { + route: 'dashboard', + source, + fixtureId: fixture.fixtureId, + sequence, + checksum: checksumText(`${fixture.teamId}:dashboard`, fixture.seed + 1), + } +} + +export function buildTeamLoaderData( + fixture: Extract, + source: 'ssr' | 'client', + sequence: number, +): HydrationResumeLoaderData { + return { + route: 'team', + source, + fixtureId: fixture.fixtureId, + sequence, + checksum: checksumText( + `${fixture.teamId}:${fixture.search.tab}:${fixture.search.cursor}`, + fixture.seed + 2, + ), + } +} + +export function buildLiveLoaderData( + fixture: HydrationResumeFixture, + itemId: string, + sequence: number, +): HydrationResumeLoaderData { + return { + route: 'live', + source: 'client', + fixtureId: fixture.fixtureId, + sequence, + checksum: checksumText(`${itemId}:live`, fixture.seed + 3), + } +} + +export function pickDashboardLoaderDeps(search: DashboardSearch) { + return { + tab: search.tab, + cursor: search.cursor, + } +} + +export function createHydrateSectionAttributes( + loaderData: HydrationResumeLoaderData, +) { + return { + 'data-hydration-resume-section': 'hydrate', + 'data-fixture-id': loaderData.fixtureId, + 'data-source': loaderData.source, + } +} + +export function createDashboardHydrationMarkerAttributes( + teamId: string, + search: DashboardSearch, + loaderData: HydrationResumeLoaderData, + teamBeforeSeed: number, +) { + return { + 'data-hydration-resume-marker': 'dashboard', + 'data-team-id': teamId, + 'data-tab': search.tab, + 'data-cursor': search.cursor, + 'data-source': loaderData.source, + 'data-context-seed': teamBeforeSeed, + } +} + +export function createLiveHydrationMarkerAttributes( + itemId: string, + loaderData: HydrationResumeLoaderData, +) { + return { + 'data-hydration-resume-marker': 'live', + 'data-item-id': itemId, + 'data-source': loaderData.source, + 'data-sequence': loaderData.sequence, + } +} + +export function createDeferredHydrationMarkerAttributes( + marker: 'deferred-shell' | 'deferred-fallback', + itemId: string, + source: HydrationResumeDeferredLoaderData['source'], +) { + return { + 'data-hydration-resume-marker': marker, + 'data-item-id': itemId, + 'data-source': source, + } +} + +export function createDeferredResolvedMarkerAttributes( + payload: HydrationResumeDeferredPayload, + source: string, +) { + return { + 'data-hydration-resume-marker': 'deferred-resolved', + 'data-item-id': payload.itemId, + 'data-source': source, + 'data-value': payload.value, + } +} + +export function createHydrationResumeRuntime() { + let counters = createEmptyCounters() + let activeFixture: HydrationResumeFixture | undefined = undefined + let lastHydratedData: unknown = undefined + const deferredRecords = new Map() + + function getActiveFixture() { + if (!activeFixture) { + throw new Error('Hydration resume fixture is not active') + } + + return activeFixture + } + + function createDeferredPayload(itemId: string, seed: number) { + return { + itemId, + value: `resolved-${itemId}`, + checksum: checksumText(`${itemId}:resolved`, seed + 4), + } satisfies HydrationResumeDeferredPayload + } + + function createDeferredLoaderData( + fixture: HydrationResumeFixture, + itemId: string, + source: 'ssr' | 'client', + sequence: number, + ): HydrationResumeDeferredLoaderData { + if (deferredRecords.has(itemId)) { + throw new Error(`Deferred hydration record already exists for ${itemId}`) + } + + let resolveDeferred: ControlledDeferred['resolve'] | undefined = undefined + const promise = new Promise((resolve) => { + resolveDeferred = resolve + }) + + if (!resolveDeferred) { + throw new Error( + `Failed to create deferred hydration record for ${itemId}`, + ) + } + + deferredRecords.set(itemId, { + promise, + resolve: resolveDeferred, + resolved: false, + }) + + return { + route: 'deferred', + source, + fixtureId: fixture.fixtureId, + itemId, + sequence, + checksum: checksumText(`${itemId}:deferred`, fixture.seed + 5), + deferred: promise, + } + } + + return { + startCycle(fixture: HydrationResumeFixture) { + counters = createEmptyCounters() + activeFixture = fixture + lastHydratedData = undefined + deferredRecords.clear() + }, + clearCycle() { + activeFixture = undefined + deferredRecords.clear() + }, + recordLoader(name: 'hydrate' | 'dashboard' | 'team' | 'live' | 'deferred') { + counters[name] += 1 + return counters[name] + }, + recordBeforeLoad(name: 'dashboardBeforeLoad' | 'teamBeforeLoad') { + counters[name] += 1 + return counters[name] + }, + recordCustomHydrate(dehydratedData: unknown) { + counters.customHydrate += 1 + lastHydratedData = dehydratedData + }, + recordHydrationComplete() { + counters.hydrationComplete += 1 + }, + recordCleanup() { + counters.cleanup += 1 + }, + getActiveFixture, + getCounters() { + return { ...counters } + }, + getLastHydratedData() { + return lastHydratedData + }, + createDeferredLoaderData, + createHydratedDeferredLoaderData( + fixture: Extract, + ) { + return createDeferredLoaderData(fixture, fixture.itemId, 'ssr', 0) + }, + createClientDeferredLoaderData(itemId: string, sequence: number) { + return createDeferredLoaderData( + getActiveFixture(), + itemId, + 'client', + sequence, + ) + }, + resolveDeferred(itemId: string) { + const record = deferredRecords.get(itemId) + + if (!record) { + throw new Error(`Missing deferred hydration record for ${itemId}`) + } + + if (record.resolved) { + return + } + + record.resolved = true + counters.deferredResolved += 1 + record.resolve(createDeferredPayload(itemId, getActiveFixture().seed)) + }, + } +} + +export type HydrationResumeRuntime = ReturnType< + typeof createHydrationResumeRuntime +> + +export function getDashboardHydrationFixture(runtime: HydrationResumeRuntime) { + const fixture = runtime.getActiveFixture() + + if (fixture.kind !== 'dashboard') { + throw new Error('Expected dashboard hydration fixture') + } + + return fixture +} + +export function buildDashboardBeforeLoadContext( + fixture: Extract, +) { + return { + dashboardBeforeSeed: fixture.seed + 7, + } +} + +export function buildTeamBeforeLoadContext( + fixture: Extract, +) { + return { + teamBeforeSeed: fixture.seed + 11, + } +} + +function buildDehydratedData(fixture: HydrationResumeFixture) { + return { + fixtureId: fixture.fixtureId, + kind: fixture.kind, + checksum: checksumText( + `${fixture.fixtureId}:hydrate-data`, + fixture.seed + 13, + ), + } +} + +function buildLoaderDataForRoute( + routeId: string, + routeIds: HydrationResumeRouteIds, + runtime: HydrationResumeRuntime, + fixture: HydrationResumeFixture, +) { + if (routeId === routeIds.hydrate) { + return buildHydrateLoaderData(fixture, 'ssr', 0) + } + + if (fixture.kind === 'dashboard') { + if (routeId === routeIds.dashboard) { + return buildDashboardLoaderData(fixture, 'ssr', 0) + } + + if (routeId === routeIds.team) { + return buildTeamLoaderData(fixture, 'ssr', 0) + } + } + + if (fixture.kind === 'deferred' && routeId === routeIds.deferred) { + return runtime.createHydratedDeferredLoaderData(fixture) + } + + return undefined +} + +function buildBeforeLoadContextForRoute( + routeId: string, + routeIds: HydrationResumeRouteIds, + fixture: HydrationResumeFixture, +) { + if (fixture.kind !== 'dashboard') { + return undefined + } + + if (routeId === routeIds.dashboard) { + return buildDashboardBeforeLoadContext(fixture) + } + + if (routeId === routeIds.team) { + return buildTeamBeforeLoadContext(fixture) + } + + return undefined +} + +export function buildHydrationResumeDehydratedRouter( + router: AnyRouter, + routeIds: HydrationResumeRouteIds, + runtime: HydrationResumeRuntime, + fixture: HydrationResumeFixture, +): DehydratedRouter { + const matches = router.matchRoutes(router.stores.location.get()) + const lastMatch = matches[matches.length - 1] + + if (!lastMatch) { + throw new Error(`No hydration resume matches for ${fixture.href}`) + } + + return { + manifest: undefined, + dehydratedData: buildDehydratedData(fixture), + lastMatchId: dehydrateMatchId(lastMatch.id), + matches: matches.map((match) => { + const dehydratedMatch: DehydratedMatch = { + i: dehydrateMatchId(match.id), + s: 'success', + ssr: true, + u: fixture.updatedAt + match.index, + } + const loaderData = buildLoaderDataForRoute( + match.routeId, + routeIds, + runtime, + fixture, + ) + const beforeLoadContext = buildBeforeLoadContextForRoute( + match.routeId, + routeIds, + fixture, + ) + + if (loaderData !== undefined) { + dehydratedMatch.l = loaderData + } + + if (beforeLoadContext !== undefined) { + dehydratedMatch.b = beforeLoadContext + } + + return dehydratedMatch + }), + } +} + +export function seedHydrationResumeSsrGlobal( + router: AnyRouter, + routeIds: HydrationResumeRouteIds, + runtime: HydrationResumeRuntime, + fixture: HydrationResumeFixture, +) { + if (typeof window === 'undefined') { + throw new Error('Hydration resume benchmark requires a window global') + } + + const hadTsr = Object.prototype.hasOwnProperty.call(window, '$_TSR') + const previousTsr = window.$_TSR + const hadR = Object.prototype.hasOwnProperty.call(window, '$R') + const previousR = window.$R + const hadRTsr = previousR + ? Object.prototype.hasOwnProperty.call(previousR, 'tsr') + : false + const previousRTsr = previousR?.tsr + let cleaned = false + let tsr: TsrSsrGlobal + + function cleanup() { + if (cleaned) { + return + } + + cleaned = true + runtime.recordCleanup() + + if (window.$_TSR === tsr) { + if (hadTsr) { + window.$_TSR = previousTsr + } else { + Reflect.deleteProperty(window, '$_TSR') + } + } + + if (window.$R) { + if (hadRTsr) { + window.$R.tsr = previousRTsr + } else { + Reflect.deleteProperty(window.$R, 'tsr') + } + } + + if (!hadR && window.$R && Object.keys(window.$R).length === 0) { + Reflect.deleteProperty(window, '$R') + } + } + + tsr = { + router: buildHydrationResumeDehydratedRouter( + router, + routeIds, + runtime, + fixture, + ), + h: () => runtime.recordHydrationComplete(), + e: () => {}, + c: cleanup, + p: (script) => { + if (tsr.initialized) { + script() + return + } + + tsr.buffer.push(script) + }, + buffer: [], + initialized: false, + } + + window.$_TSR = tsr + + return cleanup +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/project.json b/benchmarks/client-nav/scenarios/hydration-resume/solid/project.json new file mode 100644 index 0000000000..9cb6b3cb7c --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-hydration-resume-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/setup.ts b/benchmarks/client-nav/scenarios/hydration-resume/solid/setup.ts new file mode 100644 index 0000000000..6aef369d8c --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createHydrationResumeWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const appModule = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createHydrationResumeWorkload('solid', appModule) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/hydration-resume/solid/speed.bench.ts new file mode 100644 index 0000000000..8859cebd05 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav hydration-resume', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/hydration-resume/solid/speed.flame.ts new file mode 100644 index 0000000000..fdc5a87f61 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/speed.flame.ts @@ -0,0 +1,19 @@ +import { installHydrationResumeFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installHydrationResumeFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/app.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/app.tsx new file mode 100644 index 0000000000..2b37c07e15 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/app.tsx @@ -0,0 +1,78 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { + createStandaloneHydrationFixture, + seedHydrationResumeSsrGlobal, + type HydrationResumeFixture, +} from '../../shared.ts' +import { + createHydrationResumeRouter, + getHydrationResumeRouteIds, + getRouter, +} from './router' +import { hydrationResumeRuntime } from './runtime' + +export { hydrationResumeRuntime } from './runtime' + +export async function mountHydratedTestApp( + container: Element, + fixture: HydrationResumeFixture, +) { + hydrationResumeRuntime.startCycle(fixture) + + const router = createHydrationResumeRouter(fixture.href) + const cleanup = seedHydrationResumeSsrGlobal( + router, + getHydrationResumeRouteIds(), + hydrationResumeRuntime, + fixture, + ) + let didUnmount = false + let dispose: (() => void) | undefined = undefined + + try { + await hydrate(router) + window.$_TSR?.h() + dispose = render(() => , container) + } catch (error) { + cleanup() + router.history.destroy() + hydrationResumeRuntime.clearCycle() + throw error + } + + return { + router, + cleanup, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose?.() + }, + } +} + +export function mountTestApp(container: Element) { + const fixture = createStandaloneHydrationFixture() + hydrationResumeRuntime.startCycle(fixture) + + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/perf.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/perf.tsx new file mode 100644 index 0000000000..13e413124e --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/perf.tsx @@ -0,0 +1,32 @@ +import { createRenderEffect } from 'solid-js' +import { + createDeferredResolvedMarkerAttributes, + hydrationResumeSubscriberSlots, + runHydrationResumeComputation, + type HydrationResumeDeferredPayload, +} from '../../shared.ts' + +export const subscriberSlots = hydrationResumeSubscriberSlots + +export function PerfSubscriber(props: { seed: number }) { + createRenderEffect(() => { + void runHydrationResumeComputation(props.seed) + }) + + return null +} + +export function DeferredResolved(props: { + payload: HydrationResumeDeferredPayload + source: string +}) { + createRenderEffect(() => { + void runHydrationResumeComputation(props.payload.checksum) + }) + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routeTree.tsx new file mode 100644 index 0000000000..3ae7a64c11 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routeTree.tsx @@ -0,0 +1,25 @@ +import type { HydrationResumeRouteIds } from '../../shared.ts' +import { rootRoute } from './routes/__root' +import { dashboardRoute } from './routes/hydrate.dashboard' +import { teamRoute } from './routes/hydrate.dashboard.$teamId' +import { deferredRoute } from './routes/hydrate.deferred.$itemId' +import { liveRoute } from './routes/hydrate.live.$itemId' +import { hydrateRoute } from './routes/hydrate' + +export const routeTree = rootRoute.addChildren([ + hydrateRoute.addChildren([ + dashboardRoute.addChildren([teamRoute]), + deferredRoute, + liveRoute, + ]), +]) + +export function getHydrationResumeRouteIds(): HydrationResumeRouteIds { + return { + hydrate: hydrateRoute.id, + dashboard: dashboardRoute.id, + team: teamRoute.id, + deferred: deferredRoute.id, + live: liveRoute.id, + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/router.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/router.tsx new file mode 100644 index 0000000000..e243a0f084 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/router.tsx @@ -0,0 +1,33 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { + hydrationResumeRouterPendingMs, + hydrationResumeStandaloneInitialEntry, +} from '../../shared.ts' +import { routeTree, getHydrationResumeRouteIds } from './routeTree' +import { hydrationResumeRuntime } from './runtime' + +export { getHydrationResumeRouteIds } + +export function createHydrationResumeRouter(initialEntry: string) { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialEntry], + }), + routeTree, + defaultPendingMs: hydrationResumeRouterPendingMs, + defaultPendingMinMs: hydrationResumeRouterPendingMs, + hydrate: (dehydratedData: unknown) => { + hydrationResumeRuntime.recordCustomHydrate(dehydratedData) + }, + }) +} + +export function getRouter() { + return createHydrationResumeRouter(hydrationResumeStandaloneInitialEntry) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..1e9054643e --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.dashboard.$teamId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.dashboard.$teamId.tsx new file mode 100644 index 0000000000..dd12655674 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.dashboard.$teamId.tsx @@ -0,0 +1,60 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { + buildTeamBeforeLoadContext, + buildTeamLoaderData, + createDashboardHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, + normalizeDashboardSearch, + pickDashboardLoaderDeps, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { getDashboardFixture, hydrationResumeRuntime } from '../runtime' +import { dashboardRoute } from './hydrate.dashboard' + +export const teamRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: '$teamId', + validateSearch: normalizeDashboardSearch, + loaderDeps: ({ search }) => pickDashboardLoaderDeps(search), + beforeLoad: () => { + const fixture = getDashboardFixture() + hydrationResumeRuntime.recordBeforeLoad('teamBeforeLoad') + return buildTeamBeforeLoadContext(fixture) + }, + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('team') + return buildTeamLoaderData(getDashboardFixture(), 'client', sequence) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: TeamPage, +}) + +function TeamPage() { + const params = teamRoute.useParams() + const search = teamRoute.useSearch() + const loaderData = teamRoute.useLoaderData() + const routeContext = teamRoute.useRouteContext() + + return ( + <> + + {(slot) => ( + + )} + +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.dashboard.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.dashboard.tsx new file mode 100644 index 0000000000..4036946d6c --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.dashboard.tsx @@ -0,0 +1,48 @@ +import { For } from 'solid-js' +import { Outlet, createRoute } from '@tanstack/solid-router' +import { + buildDashboardBeforeLoadContext, + buildDashboardLoaderData, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { getDashboardFixture, hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +export const dashboardRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'dashboard', + beforeLoad: () => { + const fixture = getDashboardFixture() + hydrationResumeRuntime.recordBeforeLoad('dashboardBeforeLoad') + return buildDashboardBeforeLoadContext(fixture) + }, + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('dashboard') + return buildDashboardLoaderData(getDashboardFixture(), 'client', sequence) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: DashboardLayout, +}) + +function DashboardLayout() { + const loaderData = dashboardRoute.useLoaderData() + const routeContext = dashboardRoute.useRouteContext() + + return ( + <> + + {(slot) => ( + + )} + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.deferred.$itemId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.deferred.$itemId.tsx new file mode 100644 index 0000000000..00fcc40517 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.deferred.$itemId.tsx @@ -0,0 +1,62 @@ +import { Suspense } from 'solid-js' +import { Await, createRoute } from '@tanstack/solid-router' +import { + createDeferredHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, + type HydrationResumeDeferredPayload, +} from '../../../shared.ts' +import { DeferredResolved } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +export const deferredRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'deferred/$itemId', + loader: ({ params }) => { + const sequence = hydrationResumeRuntime.recordLoader('deferred') + return hydrationResumeRuntime.createClientDeferredLoaderData( + String(params.itemId), + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: DeferredPage, +}) + +function DeferredPage() { + const loaderData = deferredRoute.useLoaderData() + + return ( + <> +
+ + } + > + + {(payload) => ( + + )} + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.live.$itemId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.live.$itemId.tsx new file mode 100644 index 0000000000..2032931d97 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.live.$itemId.tsx @@ -0,0 +1,43 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { + buildLiveLoaderData, + createLiveHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +export const liveRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'live/$itemId', + loader: ({ params }) => { + const sequence = hydrationResumeRuntime.recordLoader('live') + return buildLiveLoaderData( + hydrationResumeRuntime.getActiveFixture(), + String(params.itemId), + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: LivePage, +}) + +function LivePage() { + const params = liveRoute.useParams() + const loaderData = liveRoute.useLoaderData() + + return ( + <> + + {(slot) => } + +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.tsx b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.tsx new file mode 100644 index 0000000000..8edae25004 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/routes/hydrate.tsx @@ -0,0 +1,41 @@ +import { For } from 'solid-js' +import { Outlet, createRoute } from '@tanstack/solid-router' +import { + buildHydrateLoaderData, + createHydrateSectionAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { rootRoute } from './__root' + +export const hydrateRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/hydrate', + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('hydrate') + return buildHydrateLoaderData( + hydrationResumeRuntime.getActiveFixture(), + 'client', + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: HydrateLayout, +}) + +function HydrateLayout() { + const loaderData = hydrateRoute.useLoaderData() + + return ( + <> + + {(slot) => } + +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/src/runtime.ts b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/runtime.ts new file mode 100644 index 0000000000..532941b0c5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/src/runtime.ts @@ -0,0 +1,10 @@ +import { + createHydrationResumeRuntime, + getDashboardHydrationFixture, +} from '../../shared.ts' + +export const hydrationResumeRuntime = createHydrationResumeRuntime() + +export function getDashboardFixture() { + return getDashboardHydrationFixture(hydrationResumeRuntime) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/tsconfig.json b/benchmarks/client-nav/scenarios/hydration-resume/solid/tsconfig.json new file mode 100644 index 0000000000..8a2c232fdb --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/solid/vite.config.ts b/benchmarks/client-nav/scenarios/hydration-resume/solid/vite.config.ts new file mode 100644 index 0000000000..5e202aeb15 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/solid/vite.config.ts @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav hydration-resume (solid)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/project.json b/benchmarks/client-nav/scenarios/hydration-resume/vue/project.json new file mode 100644 index 0000000000..780b6562e2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-hydration-resume-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/setup.ts b/benchmarks/client-nav/scenarios/hydration-resume/vue/setup.ts new file mode 100644 index 0000000000..da7bd036e9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createHydrationResumeWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const appModule = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createHydrationResumeWorkload('vue', appModule) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/hydration-resume/vue/speed.bench.ts new file mode 100644 index 0000000000..8859cebd05 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav hydration-resume', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/hydration-resume/vue/speed.flame.ts new file mode 100644 index 0000000000..fdc5a87f61 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/speed.flame.ts @@ -0,0 +1,19 @@ +import { installHydrationResumeFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installHydrationResumeFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/app.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/app.tsx new file mode 100644 index 0000000000..9bce7a56a3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/app.tsx @@ -0,0 +1,91 @@ +import * as Vue from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { + createStandaloneHydrationFixture, + seedHydrationResumeSsrGlobal, + type HydrationResumeFixture, +} from '../../shared.ts' +import { + createHydrationResumeRouter, + getHydrationResumeRouteIds, + getRouter, +} from './router' +import { hydrationResumeRuntime } from './runtime' + +export { hydrationResumeRuntime } from './runtime' + +function mountRouterProvider( + container: Element, + router: ReturnType, +) { + const app = Vue.createApp({ + render: () => , + }) + + app.mount(container) + + return app +} + +export async function mountHydratedTestApp( + container: Element, + fixture: HydrationResumeFixture, +) { + hydrationResumeRuntime.startCycle(fixture) + + const router = createHydrationResumeRouter(fixture.href) + const cleanup = seedHydrationResumeSsrGlobal( + router, + getHydrationResumeRouteIds(), + hydrationResumeRuntime, + fixture, + ) + let didUnmount = false + let app: Vue.App | undefined = undefined + + try { + await hydrate(router) + window.$_TSR?.h() + app = mountRouterProvider(container, router) + } catch (error) { + cleanup() + router.history.destroy() + hydrationResumeRuntime.clearCycle() + throw error + } + + return { + router, + cleanup, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app?.unmount() + }, + } +} + +export function mountTestApp(container: Element) { + const fixture = createStandaloneHydrationFixture() + hydrationResumeRuntime.startCycle(fixture) + + const router = getRouter() + const app = mountRouterProvider(container, router) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/perf.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/perf.tsx new file mode 100644 index 0000000000..f1459aa312 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/perf.tsx @@ -0,0 +1,51 @@ +import * as Vue from 'vue' +import { + createDeferredResolvedMarkerAttributes, + hydrationResumeSubscriberSlots, + runHydrationResumeComputation, + type HydrationResumeDeferredPayload, +} from '../../shared.ts' + +export const subscriberSlots = hydrationResumeSubscriberSlots + +export const PerfSubscriber = Vue.defineComponent({ + props: { + seed: { + type: Number, + required: true, + }, + }, + setup(props) { + return () => { + void runHydrationResumeComputation(props.seed) + return null + } + }, +}) + +export const DeferredResolved = Vue.defineComponent({ + props: { + payload: { + type: Object as () => HydrationResumeDeferredPayload, + required: true, + }, + source: { + type: String, + required: true, + }, + }, + setup(props) { + return () => { + void runHydrationResumeComputation(props.payload.checksum) + + return ( +
+ ) + } + }, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routeTree.tsx new file mode 100644 index 0000000000..3ae7a64c11 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routeTree.tsx @@ -0,0 +1,25 @@ +import type { HydrationResumeRouteIds } from '../../shared.ts' +import { rootRoute } from './routes/__root' +import { dashboardRoute } from './routes/hydrate.dashboard' +import { teamRoute } from './routes/hydrate.dashboard.$teamId' +import { deferredRoute } from './routes/hydrate.deferred.$itemId' +import { liveRoute } from './routes/hydrate.live.$itemId' +import { hydrateRoute } from './routes/hydrate' + +export const routeTree = rootRoute.addChildren([ + hydrateRoute.addChildren([ + dashboardRoute.addChildren([teamRoute]), + deferredRoute, + liveRoute, + ]), +]) + +export function getHydrationResumeRouteIds(): HydrationResumeRouteIds { + return { + hydrate: hydrateRoute.id, + dashboard: dashboardRoute.id, + team: teamRoute.id, + deferred: deferredRoute.id, + live: liveRoute.id, + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/router.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/router.tsx new file mode 100644 index 0000000000..d205e78a0f --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/router.tsx @@ -0,0 +1,33 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { + hydrationResumeRouterPendingMs, + hydrationResumeStandaloneInitialEntry, +} from '../../shared.ts' +import { routeTree, getHydrationResumeRouteIds } from './routeTree' +import { hydrationResumeRuntime } from './runtime' + +export { getHydrationResumeRouteIds } + +export function createHydrationResumeRouter(initialEntry: string) { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialEntry], + }), + routeTree, + defaultPendingMs: hydrationResumeRouterPendingMs, + defaultPendingMinMs: hydrationResumeRouterPendingMs, + hydrate: (dehydratedData: unknown) => { + hydrationResumeRuntime.recordCustomHydrate(dehydratedData) + }, + }) +} + +export function getRouter() { + return createHydrationResumeRouter(hydrationResumeStandaloneInitialEntry) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..1904cf90ef --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/__root.tsx @@ -0,0 +1,12 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +const Root = Vue.defineComponent({ + setup() { + return () => + }, +}) + +export const rootRoute = createRootRoute({ + component: Root, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.dashboard.$teamId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.dashboard.$teamId.tsx new file mode 100644 index 0000000000..ef0d1f2cb7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.dashboard.$teamId.tsx @@ -0,0 +1,65 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + buildTeamBeforeLoadContext, + buildTeamLoaderData, + createDashboardHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, + normalizeDashboardSearch, + pickDashboardLoaderDeps, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { getDashboardFixture, hydrationResumeRuntime } from '../runtime' +import { dashboardRoute } from './hydrate.dashboard' + +const TeamPage = Vue.defineComponent({ + setup() { + const params = teamRoute.useParams() + const search = teamRoute.useSearch() + const loaderData = teamRoute.useLoaderData() + const routeContext = teamRoute.useRouteContext() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) + }, +}) + +export const teamRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: '$teamId', + validateSearch: normalizeDashboardSearch, + loaderDeps: ({ search }) => pickDashboardLoaderDeps(search), + beforeLoad: () => { + const fixture = getDashboardFixture() + hydrationResumeRuntime.recordBeforeLoad('teamBeforeLoad') + return buildTeamBeforeLoadContext(fixture) + }, + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('team') + return buildTeamLoaderData(getDashboardFixture(), 'client', sequence) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: TeamPage, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.dashboard.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.dashboard.tsx new file mode 100644 index 0000000000..3aea7ef20a --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.dashboard.tsx @@ -0,0 +1,51 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { + buildDashboardBeforeLoadContext, + buildDashboardLoaderData, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { getDashboardFixture, hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +const DashboardLayout = Vue.defineComponent({ + setup() { + const loaderData = dashboardRoute.useLoaderData() + const routeContext = dashboardRoute.useRouteContext() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} + + + ) + }, +}) + +export const dashboardRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'dashboard', + beforeLoad: () => { + const fixture = getDashboardFixture() + hydrationResumeRuntime.recordBeforeLoad('dashboardBeforeLoad') + return buildDashboardBeforeLoadContext(fixture) + }, + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('dashboard') + return buildDashboardLoaderData(getDashboardFixture(), 'client', sequence) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: DashboardLayout, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.deferred.$itemId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.deferred.$itemId.tsx new file mode 100644 index 0000000000..3c427361cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.deferred.$itemId.tsx @@ -0,0 +1,69 @@ +import * as Vue from 'vue' +import { Suspense } from 'vue' +import { Await, createRoute } from '@tanstack/vue-router' +import { + createDeferredHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, + type HydrationResumeDeferredPayload, +} from '../../../shared.ts' +import { DeferredResolved } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +const DeferredPage = Vue.defineComponent({ + setup() { + const loaderData = deferredRoute.useLoaderData() + + return () => ( + <> +
+ + {{ + default: () => ( + ( + + )} + /> + ), + fallback: () => ( +
+ ), + }} + + + ) + }, +}) + +export const deferredRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'deferred/$itemId', + loader: ({ params }) => { + const sequence = hydrationResumeRuntime.recordLoader('deferred') + return hydrationResumeRuntime.createClientDeferredLoaderData( + String(params.itemId), + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: DeferredPage, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.live.$itemId.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.live.$itemId.tsx new file mode 100644 index 0000000000..1ac0338b0c --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.live.$itemId.tsx @@ -0,0 +1,51 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + buildLiveLoaderData, + createLiveHydrationMarkerAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { hydrateRoute } from './hydrate' + +const LivePage = Vue.defineComponent({ + setup() { + const params = liveRoute.useParams() + const loaderData = liveRoute.useLoaderData() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) + }, +}) + +export const liveRoute = createRoute({ + getParentRoute: () => hydrateRoute, + path: 'live/$itemId', + loader: ({ params }) => { + const sequence = hydrationResumeRuntime.recordLoader('live') + return buildLiveLoaderData( + hydrationResumeRuntime.getActiveFixture(), + String(params.itemId), + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: LivePage, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.tsx b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.tsx new file mode 100644 index 0000000000..1cdb1d7583 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/routes/hydrate.tsx @@ -0,0 +1,46 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { + buildHydrateLoaderData, + createHydrateSectionAttributes, + hydrationResumeRouteGcTime, + hydrationResumeRouteStaleTime, +} from '../../../shared.ts' +import { PerfSubscriber, subscriberSlots } from '../perf' +import { hydrationResumeRuntime } from '../runtime' +import { rootRoute } from './__root' + +const HydrateLayout = Vue.defineComponent({ + setup() { + const loaderData = hydrateRoute.useLoaderData() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + + ) + }, +}) + +export const hydrateRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/hydrate', + loader: () => { + const sequence = hydrationResumeRuntime.recordLoader('hydrate') + return buildHydrateLoaderData( + hydrationResumeRuntime.getActiveFixture(), + 'client', + sequence, + ) + }, + staleTime: hydrationResumeRouteStaleTime, + gcTime: hydrationResumeRouteGcTime, + component: HydrateLayout, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/src/runtime.ts b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/runtime.ts new file mode 100644 index 0000000000..532941b0c5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/src/runtime.ts @@ -0,0 +1,10 @@ +import { + createHydrationResumeRuntime, + getDashboardHydrationFixture, +} from '../../shared.ts' + +export const hydrationResumeRuntime = createHydrationResumeRuntime() + +export function getDashboardFixture() { + return getDashboardHydrationFixture(hydrationResumeRuntime) +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/tsconfig.json b/benchmarks/client-nav/scenarios/hydration-resume/vue/tsconfig.json new file mode 100644 index 0000000000..a83100c64e --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/hydration-resume/vue/vite.config.ts b/benchmarks/client-nav/scenarios/hydration-resume/vue/vite.config.ts new file mode 100644 index 0000000000..be5bbf947e --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/vue/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav hydration-resume (vue)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/hydration-resume/workload.ts b/benchmarks/client-nav/scenarios/hydration-resume/workload.ts new file mode 100644 index 0000000000..f5683f246f --- /dev/null +++ b/benchmarks/client-nav/scenarios/hydration-resume/workload.ts @@ -0,0 +1,352 @@ +import type { AnyRouter, NavigateOptions } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import { + createBenchContainer, + waitForCounter, + waitWithTimeout, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import { + createDashboardHydrationFixture, + createDeferredHydrationFixture, + createLiveNavigationTarget, + type DashboardSearch, + type HydrationResumeAppModule, + type HydrationResumeCounters, + type HydrationResumeFixture, + type HydrationResumeFramework, + type MountedHydrationResumeApp, +} from './shared' + +type ActiveHydrationMount = MountedHydrationResumeApp & { + container: Element +} + +const CYCLES_PER_RUN = 2 +const WAIT_TIMEOUT_MS = 2_000 + +function getHydrationResumeMarker( + container: ParentNode, + marker: string, +): HTMLElement | undefined { + return ( + container.querySelector( + `[data-hydration-resume-marker="${marker}"]`, + ) ?? undefined + ) +} + +function hasHydrationResumeMarker( + container: ParentNode, + marker: string, + expectedDataset: Record, +) { + const element = getHydrationResumeMarker(container, marker) + + if (!element) { + return false + } + + for (const [key, value] of Object.entries(expectedDataset)) { + if (element.dataset[key] !== value) { + return false + } + } + + return true +} + +async function waitForHydrationResumeMarker( + container: ParentNode, + marker: string, + expectedDataset: Record, +) { + await waitForCounter( + () => + hasHydrationResumeMarker(container, marker, expectedDataset) ? 1 : 0, + 1, + { + label: `hydration-resume marker ${marker}`, + timeoutMs: WAIT_TIMEOUT_MS, + }, + ) +} + +function assertCounter( + counters: HydrationResumeCounters, + name: keyof HydrationResumeCounters, + expected: number, +) { + if (counters[name] !== expected) { + throw new Error( + `Expected hydration-resume counter ${name} to be ${expected}, got ${counters[name]}`, + ) + } +} + +function assertDashboardHydrationCounters(counters: HydrationResumeCounters) { + assertCounter(counters, 'hydrate', 0) + assertCounter(counters, 'dashboard', 0) + assertCounter(counters, 'team', 0) + assertCounter(counters, 'customHydrate', 1) + assertCounter(counters, 'hydrationComplete', 1) +} + +function waitForRendered( + router: AnyRouter, + action: () => Promise | unknown, + label: string, +) { + let unsubscribe: (() => void) | undefined = undefined + const rendered = new Promise((resolve) => { + unsubscribe = router.subscribe('onRendered', () => { + unsubscribe?.() + unsubscribe = undefined + resolve() + }) + }) + + return waitWithTimeout(Promise.all([Promise.resolve(action()), rendered]), { + label, + timeoutMs: WAIT_TIMEOUT_MS, + }).finally(() => { + unsubscribe?.() + }) +} + +async function navigate( + router: AnyRouter, + options: NavigateOptions, + label: string, +) { + await waitWithTimeout(router.navigate(options), { + label, + timeoutMs: WAIT_TIMEOUT_MS, + }) +} + +async function cleanupActiveMount( + activeMount: ActiveHydrationMount | undefined, + clearRuntime: () => void, +) { + if (!activeMount) { + return + } + + const errors: Array = [] + + try { + activeMount.cleanup() + } catch (error) { + errors.push(error) + } + + try { + activeMount.unmount() + } catch (error) { + errors.push(error) + } + + try { + if ( + typeof self !== 'undefined' && + self.__TSR_ROUTER__ === activeMount.router + ) { + self.__TSR_ROUTER__ = undefined + } + } catch (error) { + errors.push(error) + } + + try { + activeMount.router.history.destroy() + } catch (error) { + errors.push(error) + } + + try { + activeMount.container.remove() + } catch (error) { + errors.push(error) + } + + try { + clearRuntime() + } catch (error) { + errors.push(error) + } + + if (errors.length === 1) { + throw errors[0] + } + + if (errors.length > 1) { + throw new AggregateError(errors, 'Hydration resume teardown failed') + } +} + +export function createHydrationResumeWorkload( + framework: HydrationResumeFramework, + appModule: HydrationResumeAppModule, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + let runIndex = 0 + let activeMount: ActiveHydrationMount | undefined = undefined + + async function mountFixture(fixture: HydrationResumeFixture) { + await cleanupActiveMount( + activeMount, + appModule.hydrationResumeRuntime.clearCycle, + ) + activeMount = undefined + + const container = createBenchContainer() + + try { + const mounted = await appModule.mountHydratedTestApp(container, fixture) + activeMount = { + ...mounted, + container, + } + return activeMount + } catch (error) { + container.remove() + appModule.hydrationResumeRuntime.clearCycle() + throw error + } + } + + async function runDashboardHydration(cycleIndex: number) { + const fixture = createDashboardHydrationFixture(cycleIndex) + const liveTarget = createLiveNavigationTarget(cycleIndex) + const mounted = await mountFixture(fixture) + + await waitForHydrationResumeMarker(mounted.container, 'dashboard', { + teamId: fixture.teamId, + tab: fixture.search.tab, + source: 'ssr', + contextSeed: String(fixture.seed + 11), + }) + + assertDashboardHydrationCounters( + appModule.hydrationResumeRuntime.getCounters(), + ) + + await navigate( + mounted.router, + { + to: '/hydrate/dashboard/$teamId', + params: { teamId: fixture.teamId }, + search: fixture.search satisfies DashboardSearch, + replace: true, + }, + 'same hydrated dashboard navigation', + ) + await waitForHydrationResumeMarker(mounted.container, 'dashboard', { + teamId: fixture.teamId, + tab: fixture.search.tab, + source: 'ssr', + contextSeed: String(fixture.seed + 11), + }) + assertDashboardHydrationCounters( + appModule.hydrationResumeRuntime.getCounters(), + ) + + await waitForRendered( + mounted.router, + () => + mounted.router.navigate({ + to: '/hydrate/live/$itemId', + params: liveTarget, + replace: true, + }), + 'first live navigation render', + ) + await waitForHydrationResumeMarker(mounted.container, 'live', { + itemId: liveTarget.itemId, + source: 'client', + }) + assertCounter(appModule.hydrationResumeRuntime.getCounters(), 'live', 1) + } + + async function runDeferredHydration(cycleIndex: number) { + const fixture = createDeferredHydrationFixture(cycleIndex) + const mounted = await mountFixture(fixture) + + await waitForHydrationResumeMarker(mounted.container, 'deferred-fallback', { + itemId: fixture.itemId, + source: 'ssr', + }) + + const countersAfterHydration = + appModule.hydrationResumeRuntime.getCounters() + assertCounter(countersAfterHydration, 'deferred', 0) + assertCounter(countersAfterHydration, 'customHydrate', 1) + assertCounter(countersAfterHydration, 'hydrationComplete', 1) + + appModule.hydrationResumeRuntime.resolveDeferred(fixture.itemId) + await waitForHydrationResumeMarker(mounted.container, 'deferred-resolved', { + itemId: fixture.itemId, + source: 'ssr', + }) + assertCounter( + appModule.hydrationResumeRuntime.getCounters(), + 'deferredResolved', + 1, + ) + } + + async function before() { + runIndex = 0 + await cleanupActiveMount( + activeMount, + appModule.hydrationResumeRuntime.clearCycle, + ) + activeMount = undefined + } + + async function run() { + const startIndex = runIndex + runIndex += CYCLES_PER_RUN + + try { + for (let index = 0; index < CYCLES_PER_RUN; index++) { + const cycleIndex = startIndex + index + await runDashboardHydration(cycleIndex) + await runDeferredHydration(cycleIndex) + } + } finally { + await cleanupActiveMount( + activeMount, + appModule.hydrationResumeRuntime.clearCycle, + ) + activeMount = undefined + } + } + + async function sanity() { + await before() + await run() + + if (typeof window !== 'undefined' && window.$_TSR) { + throw new Error('Hydration resume sanity leaked window.$_TSR') + } + } + + async function after() { + await cleanupActiveMount( + activeMount, + appModule.hydrationResumeRuntime.clearCycle, + ) + activeMount = undefined + } + + return { + name: `client hydration resume loop (${framework})`, + before, + run, + sanity, + after, + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/project.json b/benchmarks/client-nav/scenarios/interrupted-navigations/react/project.json new file mode 100644 index 0000000000..8d20c89fd5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-interrupted-navigations-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/setup.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/react/setup.ts new file mode 100644 index 0000000000..a81fc62c04 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createInterruptedNavigationsWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { interruptedNavigationRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createInterruptedNavigationsWorkload( + 'react', + mountTestApp, + interruptedNavigationRuntime, +) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/speed.bench.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/react/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/speed.flame.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/app.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/app.tsx new file mode 100644 index 0000000000..6418b9babe --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/app.tsx @@ -0,0 +1,25 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export { interruptedNavigationRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/router.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/router.tsx new file mode 100644 index 0000000000..36a4c8fec2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/router.tsx @@ -0,0 +1,33 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { interruptedNavigationHomePath } from '../../shared.ts' +import { fastRoute } from './routes/fast' +import { interruptRoute } from './routes/interrupt' +import { nestedChildRoute } from './routes/nested-child' +import { nestedParentRoute } from './routes/nested-parent' +import { rootRoute } from './routes/__root' +import { slowRoute } from './routes/slow' + +const routeTree = rootRoute.addChildren([ + interruptRoute.addChildren([ + slowRoute, + fastRoute, + nestedParentRoute.addChildren([nestedChildRoute]), + ]), +]) + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [interruptedNavigationHomePath], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/__root.tsx new file mode 100644 index 0000000000..edc04c397b --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/fast.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/fast.tsx new file mode 100644 index 0000000000..321c2507a5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/fast.tsx @@ -0,0 +1,31 @@ +import { createRoute } from '@tanstack/react-router' +import { + formatInterruptedPagePayload, + interruptedNavigationFastRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { + interruptedNavigationRuntime, + recordInterruptedCommit, +} from '../runtime' +import { interruptRoute } from './interrupt' + +export const fastRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.fast, + loader: ({ params }) => + interruptedNavigationRuntime.recordFastLoad(params.id), + ...interruptedNavigationFastRouteCacheOptions, + component: FastPage, +}) + +function FastPage() { + const data = fastRoute.useLoaderData() + recordInterruptedCommit(data) + + return ( +
+ {formatInterruptedPagePayload(data)} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/interrupt.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/interrupt.tsx new file mode 100644 index 0000000000..9dc4d46653 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/interrupt.tsx @@ -0,0 +1,29 @@ +import { Outlet, createRoute, useRouterState } from '@tanstack/react-router' +import { + interruptedNavigationHomePath, + interruptedNavigationRoutePaths, + interruptedNavigationScenarioSlug, +} from '../../../shared.ts' +import { rootRoute } from './__root' + +export const interruptRoute = createRoute({ + getParentRoute: () => rootRoute, + path: interruptedNavigationRoutePaths.home, + component: InterruptLayout, +}) + +function InterruptLayout() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + + return ( + <> +
+ {pathname === interruptedNavigationHomePath ? ( +
+ ) : null} + + + ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/nested-child.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/nested-child.tsx new file mode 100644 index 0000000000..ac4b250084 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/nested-child.tsx @@ -0,0 +1,41 @@ +import { createRoute } from '@tanstack/react-router' +import { + createNestedChildLoaderKey, + formatInterruptedNestedPayload, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { + interruptedNavigationRuntime, + recordInterruptedCommit, +} from '../runtime' +import { nestedParentRoute } from './nested-parent' + +export const nestedChildRoute = createRoute({ + getParentRoute: () => nestedParentRoute, + path: interruptedNavigationRoutePaths.nestedChild, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'nestedChild', + createNestedChildLoaderKey(params.group, params.id), + abortController.signal, + { id: params.id, group: params.group }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: NestedPage, +}) + +function NestedPage() { + const data = nestedChildRoute.useLoaderData() + recordInterruptedCommit(data) + + return ( +
+ {formatInterruptedNestedPayload(data)} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/nested-parent.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/nested-parent.tsx new file mode 100644 index 0000000000..38982eb657 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/nested-parent.tsx @@ -0,0 +1,32 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { + createNestedParentLoaderKey, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { + interruptedNavigationRuntime, + recordInterruptedCommit, +} from '../runtime' +import { interruptRoute } from './interrupt' + +export const nestedParentRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.nestedParent, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'nestedParent', + createNestedParentLoaderKey(params.group), + abortController.signal, + { id: params.group, group: params.group }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: NestedLayout, +}) + +function NestedLayout() { + const data = nestedParentRoute.useLoaderData() + recordInterruptedCommit(data) + + return +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/slow.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/slow.tsx new file mode 100644 index 0000000000..e5c44d93e4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/routes/slow.tsx @@ -0,0 +1,37 @@ +import { createRoute } from '@tanstack/react-router' +import { + createSlowLoaderKey, + formatInterruptedPagePayload, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { + interruptedNavigationRuntime, + recordInterruptedCommit, +} from '../runtime' +import { interruptRoute } from './interrupt' + +export const slowRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.slow, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'slow', + createSlowLoaderKey(params.id), + abortController.signal, + { id: params.id }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: SlowPage, +}) + +function SlowPage() { + const data = slowRoute.useLoaderData() + recordInterruptedCommit(data) + + return ( +
+ {formatInterruptedPagePayload(data)} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/runtime.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/runtime.ts new file mode 100644 index 0000000000..e032c1d6e0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/src/runtime.ts @@ -0,0 +1,11 @@ +import { + createInterruptedNavigationRuntime, + recordInterruptedNavigationCommit, +} from '../../shared.ts' +import type { InterruptedLoaderPayload } from '../../shared.ts' + +export const interruptedNavigationRuntime = createInterruptedNavigationRuntime() + +export function recordInterruptedCommit(payload: InterruptedLoaderPayload) { + recordInterruptedNavigationCommit(interruptedNavigationRuntime, payload) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/tsconfig.json b/benchmarks/client-nav/scenarios/interrupted-navigations/react/tsconfig.json new file mode 100644 index 0000000000..e5056ec745 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/react/vite.config.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/react/vite.config.ts new file mode 100644 index 0000000000..0ba00a117d --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav interrupted-navigations (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/shared.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/shared.ts new file mode 100644 index 0000000000..2b4c3897cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/shared.ts @@ -0,0 +1,835 @@ +import type { NavigateOptions } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' + +export type InterruptedControlledLoaderKind = + | 'slow' + | 'nestedParent' + | 'nestedChild' + +export type InterruptedLoaderKind = InterruptedControlledLoaderKind | 'fast' + +export interface InterruptedLoaderPayload { + kind: InterruptedLoaderKind + key: string + id: string + group: string | undefined + sequence: number + checksum: number +} + +export interface InterruptedNavigationCounters { + started: Record + resolved: Record + aborted: Record + committed: Record +} + +export interface InterruptedNavigationRuntime { + createControlledLoad: ( + kind: InterruptedControlledLoaderKind, + key: string, + signal: AbortSignal, + details: { id: string; group?: string }, + ) => Promise + getPendingCount: (kind?: InterruptedControlledLoaderKind) => number + hasPending: (kind: InterruptedControlledLoaderKind, key: string) => boolean + recordCommit: (payload: InterruptedLoaderPayload) => void + recordFastLoad: (id: string) => InterruptedLoaderPayload + reset: () => void + resolveAllControlledLoads: (kind?: InterruptedControlledLoaderKind) => void + resolveControlledLoad: ( + kind: InterruptedControlledLoaderKind, + key: string, + ) => void + snapshot: () => InterruptedNavigationCounters +} + +type NavigationSettlement = + | { + status: 'fulfilled' + value: void + } + | { + status: 'rejected' + reason: unknown + } + +interface PendingControlledLoad { + kind: InterruptedControlledLoaderKind + key: string + resolve: () => void +} + +export const interruptedNavigationScenarioSlug = 'interrupted-navigations' +export const interruptedNavigationRoutePaths = { + home: '/interrupt', + fastFull: '/interrupt/fast/$id', + slowFull: '/interrupt/slow/$id', + nestedFull: '/interrupt/nested/$group/$id', + fast: 'fast/$id', + slow: 'slow/$id', + nestedParent: 'nested/$group', + nestedChild: '$id', +} as const +export const interruptedNavigationHomePath = + interruptedNavigationRoutePaths.home +export const interruptedNavigationFastRouteCacheOptions = { + staleTime: 0, + gcTime: 0, +} as const +export const interruptedNavigationControlledRouteCacheOptions = { + gcTime: 0, +} as const + +const interruptedNavigationGroupCount = 10 +const random = createDeterministicRandom(11_011) + +export const interruptedNavigationInputs = Array.from( + { length: interruptedNavigationGroupCount }, + (_, index) => createInterruptedNavigationInput(index), +) + +export const interruptedNavigationSanityInput = + createInterruptedNavigationInput(10_001) + +export function createSlowLoaderKey(id: string) { + return `slow:${id}` +} + +export function createNestedParentLoaderKey(group: string) { + return `nested-parent:${group}` +} + +export function createNestedChildLoaderKey(group: string, id: string) { + return `nested-child:${group}:${id}` +} + +export function runInterruptedNavigationComputation(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 36; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function recordInterruptedNavigationCommit( + runtime: InterruptedNavigationRuntime, + payload: InterruptedLoaderPayload, +) { + runtime.recordCommit(payload) + void runInterruptedNavigationComputation(payload.checksum) +} + +export function formatInterruptedPagePayload( + payload: InterruptedLoaderPayload, +) { + return [payload.kind, payload.id, payload.sequence, payload.checksum].join( + ':', + ) +} + +export function formatInterruptedNestedPayload( + payload: InterruptedLoaderPayload, +) { + return [ + payload.kind, + payload.group, + payload.id, + payload.sequence, + payload.checksum, + ].join(':') +} + +export function createInterruptedNavigationRuntime(): InterruptedNavigationRuntime { + let counters = createEmptyCounters() + const pendingLoads = new Map() + const committedLoads = new Set() + + function resolveAllControlledLoads(kind?: InterruptedControlledLoaderKind) { + for (const pending of Array.from(pendingLoads.values())) { + if (kind !== undefined && pending.kind !== kind) { + continue + } + + pending.resolve() + } + } + + return { + createControlledLoad(kind, key, signal, details) { + const mapKey = createMapKey(kind, key) + counters.started[kind] += 1 + const sequence = counters.started[kind] + + return new Promise((resolve, reject) => { + let didSettle = false + + const cleanup = () => { + signal.removeEventListener('abort', onAbort) + pendingLoads.delete(mapKey) + } + + const settleAsResolved = () => { + if (didSettle) { + return + } + + didSettle = true + cleanup() + counters.resolved[kind] += 1 + resolve(buildLoaderPayload(kind, key, sequence, details)) + } + + const settleAsAborted = () => { + if (didSettle) { + return + } + + didSettle = true + cleanup() + counters.aborted[kind] += 1 + reject(createAbortError()) + } + + function onAbort() { + settleAsAborted() + } + + pendingLoads.set(mapKey, { + kind, + key, + resolve: settleAsResolved, + }) + + signal.addEventListener('abort', onAbort, { once: true }) + + if (signal.aborted) { + settleAsAborted() + } + }) + }, + getPendingCount(kind) { + if (kind === undefined) { + return pendingLoads.size + } + + let count = 0 + + for (const pending of pendingLoads.values()) { + if (pending.kind === kind) { + count += 1 + } + } + + return count + }, + hasPending(kind, key) { + return pendingLoads.has(createMapKey(kind, key)) + }, + recordCommit(payload) { + const key = createMapKey(payload.kind, payload.key) + + if (committedLoads.has(key)) { + return + } + + committedLoads.add(key) + counters.committed[payload.kind] += 1 + }, + recordFastLoad(id) { + const kind = 'fast' + const key = `fast:${id}` + counters.started[kind] += 1 + counters.resolved[kind] += 1 + + return buildLoaderPayload(kind, key, counters.started[kind], { id }) + }, + reset() { + resolveAllControlledLoads() + pendingLoads.clear() + committedLoads.clear() + counters = createEmptyCounters() + }, + resolveAllControlledLoads, + resolveControlledLoad(kind, key) { + const pending = pendingLoads.get(createMapKey(kind, key)) + + if (!pending) { + throw new Error(`No pending ${kind} loader for key: ${key}`) + } + + pending.resolve() + }, + snapshot() { + return cloneCounters(counters) + }, + } +} + +export function createInterruptedNavigationsWorkload( + framework: Framework, + mountTestApp: MountTestApp, + runtime: InterruptedNavigationRuntime, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp }) + + function getPageMarker() { + return lifecycle + .getContainer() + .querySelector('[data-interrupted-page]') + } + + function assertRenderedPage( + page: 'home' | 'fast' | 'nested', + expected: { id?: string; group?: string } = {}, + ) { + const marker = getPageMarker() + const actualPage = marker?.dataset.interruptedPage + + if (actualPage !== page) { + throw new Error(`Expected interrupted page ${page}, got ${actualPage}`) + } + + if ( + expected.id !== undefined && + marker?.dataset.interruptedId !== expected.id + ) { + throw new Error( + `Expected interrupted id ${expected.id}, got ${marker?.dataset.interruptedId}`, + ) + } + + if ( + expected.group !== undefined && + marker?.dataset.interruptedGroup !== expected.group + ) { + throw new Error( + `Expected interrupted group ${expected.group}, got ${marker?.dataset.interruptedGroup}`, + ) + } + } + + async function waitForPage( + page: 'home' | 'fast' | 'nested', + expected: { id?: string; group?: string } = {}, + ) { + await lifecycle.waitForCounter( + () => { + try { + assertRenderedPage(page, expected) + return 1 + } catch { + return 0 + } + }, + 1, + { label: `${page} interrupted page marker` }, + ) + } + + async function waitForPendingLoader( + kind: InterruptedControlledLoaderKind, + key: string, + ) { + await lifecycle.waitForCounter( + () => (runtime.hasPending(kind, key) ? 1 : 0), + 1, + { label: `${kind} pending loader ${key}` }, + ) + } + + function getLatestLoadPromise(label: string) { + const loadPromise = lifecycle.getRouter().latestLoadPromise + + if (!loadPromise) { + throw new Error(`${label} did not create a router load promise`) + } + + return loadPromise + } + + function startNavigation(options: NavigateOptions) { + return captureNavigation(lifecycle.getRouter().navigate(options)) + } + + async function waitForExpectedLoadSettlement( + loadPromise: Promise, + label: string, + ) { + assertSupersededNavigation( + await lifecycle.waitForPromise(captureNavigation(loadPromise), { label }), + label, + ) + } + + async function navigateFast(id: string) { + await lifecycle.navigate( + { + to: interruptedNavigationRoutePaths.fastFull, + params: { id }, + replace: true, + }, + { wait: 'rendered', label: `fast navigation ${id}` }, + ) + await waitForPage('fast', { id }) + } + + async function startSlowNavigation(id: string) { + const key = createSlowLoaderKey(id) + const settlement = startNavigation({ + to: interruptedNavigationRoutePaths.slowFull, + params: { id }, + replace: true, + }) + + await waitForPendingLoader('slow', key) + + return { + key, + settlement, + loadPromise: getLatestLoadPromise(`slow navigation ${id}`), + } + } + + async function startNestedNavigation(group: string, id: string) { + const parentKey = createNestedParentLoaderKey(group) + const childKey = createNestedChildLoaderKey(group, id) + const settlement = startNavigation({ + to: interruptedNavigationRoutePaths.nestedFull, + params: { group, id }, + replace: true, + }) + + await waitForPendingLoader('nestedParent', parentKey) + await waitForPendingLoader('nestedChild', childKey) + + return { + parentKey, + childKey, + settlement, + loadPromise: getLatestLoadPromise(`nested navigation ${group}/${id}`), + } + } + + async function completeLatestNestedNavigation( + input: InterruptedNavigationInput, + ) { + const staleNested = await startNestedNavigation( + input.nestedStaleGroup, + input.nestedStaleId, + ) + const latestNested = await startNestedNavigation( + input.nestedFinalGroup, + input.nestedFinalId, + ) + + await lifecycle.waitForRender( + async () => { + runtime.resolveControlledLoad('nestedParent', latestNested.parentKey) + runtime.resolveControlledLoad('nestedChild', latestNested.childKey) + assertFulfilledNavigation( + await lifecycle.waitForPromise(latestNested.settlement, { + label: `latest nested navigation ${input.nestedFinalGroup}/${input.nestedFinalId}`, + }), + 'latest nested navigation', + ) + await waitForExpectedLoadSettlement( + latestNested.loadPromise, + `latest nested load ${input.nestedFinalGroup}/${input.nestedFinalId}`, + ) + }, + { + label: `latest nested render ${input.nestedFinalGroup}/${input.nestedFinalId}`, + }, + ) + + await waitForPage('nested', { + group: input.nestedFinalGroup, + id: input.nestedFinalId, + }) + + runtime.resolveAllControlledLoads('nestedParent') + runtime.resolveAllControlledLoads('nestedChild') + + assertSupersededNavigation( + await lifecycle.waitForPromise(staleNested.settlement, { + label: `stale nested navigation ${input.nestedStaleGroup}/${input.nestedStaleId}`, + }), + 'stale nested navigation', + ) + await waitForExpectedLoadSettlement( + staleNested.loadPromise, + `stale nested load ${input.nestedStaleGroup}/${input.nestedStaleId}`, + ) + } + + async function runInterruptedGroup( + input: InterruptedNavigationInput, + assertCounters: boolean, + ) { + const before = runtime.snapshot() + const slowOne = await startSlowNavigation(input.slowOneId) + const slowTwo = await startSlowNavigation(input.slowTwoId) + + await navigateFast(input.fastId) + runtime.resolveAllControlledLoads('slow') + + assertSupersededNavigation( + await lifecycle.waitForPromise(slowOne.settlement, { + label: `first slow navigation ${input.slowOneId}`, + }), + 'first slow navigation', + ) + assertSupersededNavigation( + await lifecycle.waitForPromise(slowTwo.settlement, { + label: `second slow navigation ${input.slowTwoId}`, + }), + 'second slow navigation', + ) + await waitForExpectedLoadSettlement( + slowOne.loadPromise, + `first slow load ${input.slowOneId}`, + ) + await waitForExpectedLoadSettlement( + slowTwo.loadPromise, + `second slow load ${input.slowTwoId}`, + ) + await waitForPage('fast', { id: input.fastId }) + + await completeLatestNestedNavigation(input) + + if (assertCounters) { + assertGroupCounters(before, runtime.snapshot()) + } + } + + async function before() { + runtime.reset() + await lifecycle.before() + await waitForPage('home') + } + + async function after() { + runtime.resolveAllControlledLoads() + await lifecycle.after() + runtime.reset() + } + + return { + name: `client interrupted navigations loop (${framework})`, + before, + async run() { + for (const input of interruptedNavigationInputs) { + await runInterruptedGroup(input, false) + } + }, + async sanity() { + await before() + + try { + await runInterruptedGroup(interruptedNavigationSanityInput, true) + assertRenderedPage('nested', { + group: interruptedNavigationSanityInput.nestedFinalGroup, + id: interruptedNavigationSanityInput.nestedFinalId, + }) + + const counters = runtime.snapshot() + + if (counters.committed.slow !== 0) { + throw new Error('A stale slow route committed after interruption') + } + + if (runtime.getPendingCount() !== 0) { + throw new Error('Interrupted navigation sanity left pending loaders') + } + } finally { + await after() + } + }, + after, + } +} + +interface InterruptedNavigationInput { + slowOneId: string + slowTwoId: string + fastId: string + nestedStaleGroup: string + nestedStaleId: string + nestedFinalGroup: string + nestedFinalId: string +} + +function createInterruptedNavigationInput( + index: number, +): InterruptedNavigationInput { + return { + slowOneId: token('slow-a', index), + slowTwoId: token('slow-b', index), + fastId: token('fast', index), + nestedStaleGroup: token('nested-stale-group', index), + nestedStaleId: token('nested-stale-id', index), + nestedFinalGroup: token('nested-final-group', index), + nestedFinalId: token('nested-final-id', index), + } +} + +function token(prefix: string, index: number) { + return `${prefix}-${index}-${randomSegment(random)}` +} + +function createEmptyCounters(): InterruptedNavigationCounters { + return { + started: { + slow: 0, + fast: 0, + nestedParent: 0, + nestedChild: 0, + }, + resolved: { + slow: 0, + fast: 0, + nestedParent: 0, + nestedChild: 0, + }, + aborted: { + slow: 0, + nestedParent: 0, + nestedChild: 0, + }, + committed: { + slow: 0, + fast: 0, + nestedParent: 0, + nestedChild: 0, + }, + } +} + +function cloneCounters( + counters: InterruptedNavigationCounters, +): InterruptedNavigationCounters { + return { + started: { ...counters.started }, + resolved: { ...counters.resolved }, + aborted: { ...counters.aborted }, + committed: { ...counters.committed }, + } +} + +function createMapKey(kind: InterruptedLoaderKind, key: string) { + return `${kind}:${key}` +} + +function buildLoaderPayload( + kind: InterruptedLoaderKind, + key: string, + sequence: number, + details: { id: string; group?: string }, +): InterruptedLoaderPayload { + const checksum = runInterruptedNavigationComputation( + stringSeed(`${kind}:${key}:${details.id}:${details.group ?? ''}`) + + sequence, + ) + + return { + kind, + key, + id: details.id, + group: details.group, + sequence, + checksum, + } +} + +function stringSeed(value: string) { + let seed = 0 + + for (let index = 0; index < value.length; index++) { + seed = (seed * 31 + value.charCodeAt(index)) >>> 0 + } + + return seed +} + +function createAbortError() { + if (typeof DOMException === 'function') { + return new DOMException( + 'Interrupted navigation loader aborted', + 'AbortError', + ) + } + + const error = new Error('Interrupted navigation loader aborted') + error.name = 'AbortError' + return error +} + +function captureNavigation( + value: Promise, +): Promise { + return value + .then( + (result): NavigationSettlement => ({ + status: 'fulfilled', + value: result, + }), + ) + .catch( + (reason: unknown): NavigationSettlement => ({ + status: 'rejected', + reason, + }), + ) +} + +function assertFulfilledNavigation( + settlement: NavigationSettlement, + label: string, +) { + if (settlement.status === 'fulfilled') { + return + } + + throw new Error(`${label} rejected: ${formatReason(settlement.reason)}`) +} + +function assertSupersededNavigation( + settlement: NavigationSettlement, + label: string, +) { + if (settlement.status === 'fulfilled') { + return + } + + if (reasonHasAbortShape(settlement.reason)) { + return + } + + if (reasonHasCancellationShape(settlement.reason)) { + return + } + + throw new Error( + `${label} rejected unexpectedly: ${formatReason(settlement.reason)}`, + ) +} + +function reasonHasAbortShape(reason: unknown) { + return reason instanceof DOMException && reason.name === 'AbortError' +} + +function reasonHasCancellationShape(reason: unknown) { + return ( + reason instanceof Error && + (reason.name === 'AbortError' || reason.name === 'CancelledError') + ) +} + +function formatReason(reason: unknown) { + if (reason instanceof Error) { + return `${reason.name}: ${reason.message}` + } + + return String(reason) +} + +function assertCounterDelta( + label: string, + before: number, + after: number, + expectedDelta: number, +) { + const actualDelta = after - before + + if (actualDelta !== expectedDelta) { + throw new Error( + `${label}: expected delta ${expectedDelta}, got ${actualDelta}`, + ) + } +} + +function assertGroupCounters( + before: InterruptedNavigationCounters, + after: InterruptedNavigationCounters, +) { + assertCounterDelta( + 'slow loader starts', + before.started.slow, + after.started.slow, + 2, + ) + assertCounterDelta( + 'fast loader starts', + before.started.fast, + after.started.fast, + 1, + ) + assertCounterDelta( + 'nested parent starts', + before.started.nestedParent, + after.started.nestedParent, + 2, + ) + assertCounterDelta( + 'nested child starts', + before.started.nestedChild, + after.started.nestedChild, + 2, + ) + assertCounterDelta( + 'slow loader aborts', + before.aborted.slow, + after.aborted.slow, + 2, + ) + assertCounterDelta( + 'nested parent aborts', + before.aborted.nestedParent, + after.aborted.nestedParent, + 1, + ) + assertCounterDelta( + 'nested child aborts', + before.aborted.nestedChild, + after.aborted.nestedChild, + 1, + ) + assertCounterDelta( + 'slow commits', + before.committed.slow, + after.committed.slow, + 0, + ) + assertCounterDelta( + 'fast commits', + before.committed.fast, + after.committed.fast, + 1, + ) + assertCounterDelta( + 'nested parent commits', + before.committed.nestedParent, + after.committed.nestedParent, + 1, + ) + assertCounterDelta( + 'nested child commits', + before.committed.nestedChild, + after.committed.nestedChild, + 1, + ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/project.json b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/project.json new file mode 100644 index 0000000000..ea7b721ef4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-interrupted-navigations-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/setup.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/setup.ts new file mode 100644 index 0000000000..20053ead0f --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createInterruptedNavigationsWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { interruptedNavigationRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createInterruptedNavigationsWorkload( + 'solid', + mountTestApp, + interruptedNavigationRuntime, +) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/app.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/app.tsx new file mode 100644 index 0000000000..1ddca48474 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/app.tsx @@ -0,0 +1,23 @@ +import { RouterProvider } from '@tanstack/solid-router' +import { render } from 'solid-js/web' +import { getRouter } from './router' + +export { interruptedNavigationRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/router.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/router.tsx new file mode 100644 index 0000000000..af463e1f06 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/router.tsx @@ -0,0 +1,33 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { interruptedNavigationHomePath } from '../../shared.ts' +import { fastRoute } from './routes/fast' +import { interruptRoute } from './routes/interrupt' +import { nestedChildRoute } from './routes/nested-child' +import { nestedParentRoute } from './routes/nested-parent' +import { rootRoute } from './routes/__root' +import { slowRoute } from './routes/slow' + +const routeTree = rootRoute.addChildren([ + interruptRoute.addChildren([ + slowRoute, + fastRoute, + nestedParentRoute.addChildren([nestedChildRoute]), + ]), +]) + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [interruptedNavigationHomePath], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..1e9054643e --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/fast.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/fast.tsx new file mode 100644 index 0000000000..5994432894 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/fast.tsx @@ -0,0 +1,28 @@ +import { createRoute } from '@tanstack/solid-router' +import { + formatInterruptedPagePayload, + interruptedNavigationFastRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { CommitEffect, interruptedNavigationRuntime } from '../runtime' +import { interruptRoute } from './interrupt' + +export const fastRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.fast, + loader: ({ params }) => + interruptedNavigationRuntime.recordFastLoad(params.id), + ...interruptedNavigationFastRouteCacheOptions, + component: FastPage, +}) + +function FastPage() { + const data = fastRoute.useLoaderData() + + return ( +
+ + {formatInterruptedPagePayload(data())} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/interrupt.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/interrupt.tsx new file mode 100644 index 0000000000..e18f7eef53 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/interrupt.tsx @@ -0,0 +1,30 @@ +import { Show } from 'solid-js' +import { Outlet, createRoute, useRouterState } from '@tanstack/solid-router' +import { + interruptedNavigationHomePath, + interruptedNavigationRoutePaths, + interruptedNavigationScenarioSlug, +} from '../../../shared.ts' +import { rootRoute } from './__root' + +export const interruptRoute = createRoute({ + getParentRoute: () => rootRoute, + path: interruptedNavigationRoutePaths.home, + component: InterruptLayout, +}) + +function InterruptLayout() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + + return ( + <> +
+ +
+ + + + ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/nested-child.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/nested-child.tsx new file mode 100644 index 0000000000..a2e9e40c60 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/nested-child.tsx @@ -0,0 +1,38 @@ +import { createRoute } from '@tanstack/solid-router' +import { + createNestedChildLoaderKey, + formatInterruptedNestedPayload, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { CommitEffect, interruptedNavigationRuntime } from '../runtime' +import { nestedParentRoute } from './nested-parent' + +export const nestedChildRoute = createRoute({ + getParentRoute: () => nestedParentRoute, + path: interruptedNavigationRoutePaths.nestedChild, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'nestedChild', + createNestedChildLoaderKey(params.group, params.id), + abortController.signal, + { id: params.id, group: params.group }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: NestedPage, +}) + +function NestedPage() { + const data = nestedChildRoute.useLoaderData() + + return ( +
+ + {formatInterruptedNestedPayload(data())} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/nested-parent.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/nested-parent.tsx new file mode 100644 index 0000000000..f78a2a12a2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/nested-parent.tsx @@ -0,0 +1,33 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { + createNestedParentLoaderKey, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { CommitEffect, interruptedNavigationRuntime } from '../runtime' +import { interruptRoute } from './interrupt' + +export const nestedParentRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.nestedParent, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'nestedParent', + createNestedParentLoaderKey(params.group), + abortController.signal, + { id: params.group, group: params.group }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: NestedLayout, +}) + +function NestedLayout() { + const data = nestedParentRoute.useLoaderData() + + return ( + <> + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/slow.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/slow.tsx new file mode 100644 index 0000000000..b02a027fb9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/routes/slow.tsx @@ -0,0 +1,34 @@ +import { createRoute } from '@tanstack/solid-router' +import { + createSlowLoaderKey, + formatInterruptedPagePayload, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { CommitEffect, interruptedNavigationRuntime } from '../runtime' +import { interruptRoute } from './interrupt' + +export const slowRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.slow, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'slow', + createSlowLoaderKey(params.id), + abortController.signal, + { id: params.id }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: SlowPage, +}) + +function SlowPage() { + const data = slowRoute.useLoaderData() + + return ( +
+ + {formatInterruptedPagePayload(data())} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/runtime.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/runtime.ts new file mode 100644 index 0000000000..a54c6848bf --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/src/runtime.ts @@ -0,0 +1,19 @@ +import { createRenderEffect } from 'solid-js' +import { + createInterruptedNavigationRuntime, + recordInterruptedNavigationCommit, +} from '../../shared.ts' +import type { InterruptedLoaderPayload } from '../../shared.ts' + +export const interruptedNavigationRuntime = createInterruptedNavigationRuntime() + +export function CommitEffect(props: { payload: InterruptedLoaderPayload }) { + createRenderEffect(() => { + recordInterruptedNavigationCommit( + interruptedNavigationRuntime, + props.payload, + ) + }) + + return null +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/tsconfig.json b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/tsconfig.json new file mode 100644 index 0000000000..b549cd9fe8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/solid/vite.config.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/vite.config.ts new file mode 100644 index 0000000000..1ddd4c7b90 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/solid/vite.config.ts @@ -0,0 +1,42 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav interrupted-navigations (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/project.json b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/project.json new file mode 100644 index 0000000000..6071e31db4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-interrupted-navigations-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/setup.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/setup.ts new file mode 100644 index 0000000000..cac27b354d --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createInterruptedNavigationsWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { interruptedNavigationRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createInterruptedNavigationsWorkload( + 'vue', + mountTestApp, + interruptedNavigationRuntime, +) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/app.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/app.tsx new file mode 100644 index 0000000000..13503a0587 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/app.tsx @@ -0,0 +1,27 @@ +import * as Vue from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { getRouter } from './router' + +export { interruptedNavigationRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = Vue.createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/router.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/router.tsx new file mode 100644 index 0000000000..d42cf5acc2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/router.tsx @@ -0,0 +1,33 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { interruptedNavigationHomePath } from '../../shared.ts' +import { fastRoute } from './routes/fast' +import { interruptRoute } from './routes/interrupt' +import { nestedChildRoute } from './routes/nested-child' +import { nestedParentRoute } from './routes/nested-parent' +import { rootRoute } from './routes/__root' +import { slowRoute } from './routes/slow' + +const routeTree = rootRoute.addChildren([ + interruptRoute.addChildren([ + slowRoute, + fastRoute, + nestedParentRoute.addChildren([nestedChildRoute]), + ]), +]) + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [interruptedNavigationHomePath], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..60aefdc86e --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/__root.tsx @@ -0,0 +1,12 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +const Root: ReturnType = Vue.defineComponent({ + setup() { + return () => + }, +}) + +export const rootRoute = createRootRoute({ + component: Root, +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/fast.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/fast.tsx new file mode 100644 index 0000000000..2c0281d541 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/fast.tsx @@ -0,0 +1,37 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + formatInterruptedPagePayload, + interruptedNavigationFastRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { + interruptedNavigationRuntime, + recordInterruptedCommit, +} from '../runtime' +import { interruptRoute } from './interrupt' + +const FastPage: ReturnType = Vue.defineComponent({ + setup() { + const data = fastRoute.useLoaderData() + + return () => { + recordInterruptedCommit(data.value) + + return ( +
+ {formatInterruptedPagePayload(data.value)} +
+ ) + } + }, +}) + +export const fastRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.fast, + loader: ({ params }) => + interruptedNavigationRuntime.recordFastLoad(params.id), + ...interruptedNavigationFastRouteCacheOptions, + component: FastPage, +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/interrupt.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/interrupt.tsx new file mode 100644 index 0000000000..fc4190903b --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/interrupt.tsx @@ -0,0 +1,33 @@ +import * as Vue from 'vue' +import { Outlet, createRoute, useRouterState } from '@tanstack/vue-router' +import { + interruptedNavigationHomePath, + interruptedNavigationRoutePaths, + interruptedNavigationScenarioSlug, +} from '../../../shared.ts' +import { rootRoute } from './__root' + +const InterruptLayout: ReturnType = + Vue.defineComponent({ + setup() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + + return () => ( + <> +
+ {pathname.value === interruptedNavigationHomePath ? ( +
+ ) : null} + + + ) + }, + }) + +export const interruptRoute = createRoute({ + getParentRoute: () => rootRoute, + path: interruptedNavigationRoutePaths.home, + component: InterruptLayout, +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/nested-child.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/nested-child.tsx new file mode 100644 index 0000000000..c1de51a796 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/nested-child.tsx @@ -0,0 +1,47 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + createNestedChildLoaderKey, + formatInterruptedNestedPayload, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { + interruptedNavigationRuntime, + recordInterruptedCommit, +} from '../runtime' +import { nestedParentRoute } from './nested-parent' + +const NestedPage: ReturnType = Vue.defineComponent({ + setup() { + const data = nestedChildRoute.useLoaderData() + + return () => { + recordInterruptedCommit(data.value) + + return ( +
+ {formatInterruptedNestedPayload(data.value)} +
+ ) + } + }, +}) + +export const nestedChildRoute = createRoute({ + getParentRoute: () => nestedParentRoute, + path: interruptedNavigationRoutePaths.nestedChild, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'nestedChild', + createNestedChildLoaderKey(params.group, params.id), + abortController.signal, + { id: params.id, group: params.group }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: NestedPage, +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/nested-parent.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/nested-parent.tsx new file mode 100644 index 0000000000..1156caf03b --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/nested-parent.tsx @@ -0,0 +1,38 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { + createNestedParentLoaderKey, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { + interruptedNavigationRuntime, + recordInterruptedCommit, +} from '../runtime' +import { interruptRoute } from './interrupt' + +const NestedLayout: ReturnType = + Vue.defineComponent({ + setup() { + const data = nestedParentRoute.useLoaderData() + + return () => { + recordInterruptedCommit(data.value) + return + } + }, + }) + +export const nestedParentRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.nestedParent, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'nestedParent', + createNestedParentLoaderKey(params.group), + abortController.signal, + { id: params.group, group: params.group }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: NestedLayout, +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/slow.tsx b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/slow.tsx new file mode 100644 index 0000000000..bbd6c9ad3c --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/routes/slow.tsx @@ -0,0 +1,43 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + createSlowLoaderKey, + formatInterruptedPagePayload, + interruptedNavigationControlledRouteCacheOptions, + interruptedNavigationRoutePaths, +} from '../../../shared.ts' +import { + interruptedNavigationRuntime, + recordInterruptedCommit, +} from '../runtime' +import { interruptRoute } from './interrupt' + +const SlowPage: ReturnType = Vue.defineComponent({ + setup() { + const data = slowRoute.useLoaderData() + + return () => { + recordInterruptedCommit(data.value) + + return ( +
+ {formatInterruptedPagePayload(data.value)} +
+ ) + } + }, +}) + +export const slowRoute = createRoute({ + getParentRoute: () => interruptRoute, + path: interruptedNavigationRoutePaths.slow, + loader: ({ params, abortController }) => + interruptedNavigationRuntime.createControlledLoad( + 'slow', + createSlowLoaderKey(params.id), + abortController.signal, + { id: params.id }, + ), + ...interruptedNavigationControlledRouteCacheOptions, + component: SlowPage, +}) diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/runtime.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/runtime.ts new file mode 100644 index 0000000000..e032c1d6e0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/src/runtime.ts @@ -0,0 +1,11 @@ +import { + createInterruptedNavigationRuntime, + recordInterruptedNavigationCommit, +} from '../../shared.ts' +import type { InterruptedLoaderPayload } from '../../shared.ts' + +export const interruptedNavigationRuntime = createInterruptedNavigationRuntime() + +export function recordInterruptedCommit(payload: InterruptedLoaderPayload) { + recordInterruptedNavigationCommit(interruptedNavigationRuntime, payload) +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/tsconfig.json b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/tsconfig.json new file mode 100644 index 0000000000..24bdb3e3cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/interrupted-navigations/vue/vite.config.ts b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/vite.config.ts new file mode 100644 index 0000000000..80c2a42457 --- /dev/null +++ b/benchmarks/client-nav/scenarios/interrupted-navigations/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav interrupted-navigations (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/project.json b/benchmarks/client-nav/scenarios/loader-cache/react/project.json new file mode 100644 index 0000000000..0948d4192c --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-loader-cache-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/setup.ts b/benchmarks/client-nav/scenarios/loader-cache/react/setup.ts new file mode 100644 index 0000000000..5193142c73 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createLoaderCacheWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { loaderCacheRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createLoaderCacheWorkload( + 'react', + mountTestApp, + loaderCacheRuntime, +) diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/speed.bench.ts b/benchmarks/client-nav/scenarios/loader-cache/react/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/speed.flame.ts b/benchmarks/client-nav/scenarios/loader-cache/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/app.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/app.tsx new file mode 100644 index 0000000000..7b552a8e87 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/app.tsx @@ -0,0 +1,24 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export { loaderCacheRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routeTree.tsx new file mode 100644 index 0000000000..367edcafa2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routeTree.tsx @@ -0,0 +1,18 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as dataRoute } from './routes/data' +import { Route as listRoute } from './routes/data.list' +import { Route as itemRoute } from './routes/data.list.$itemId' +import { Route as staleRoute } from './routes/data.stale' +import { Route as blockingRoute } from './routes/data.blocking' +import { Route as conditionalRoute } from './routes/data.conditional' +import { Route as evictRoute } from './routes/data.evict.$bucketId' + +export const routeTree = rootRoute.addChildren([ + dataRoute.addChildren([ + listRoute.addChildren([itemRoute]), + staleRoute, + blockingRoute, + conditionalRoute, + evictRoute, + ]), +]) diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/router.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/router.tsx new file mode 100644 index 0000000000..2c16828b5b --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/router.tsx @@ -0,0 +1,20 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { loaderCacheInitialEntry } from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [loaderCacheInitialEntry], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/__root.tsx new file mode 100644 index 0000000000..889395056b --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.blocking.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.blocking.tsx new file mode 100644 index 0000000000..24a305b793 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.blocking.tsx @@ -0,0 +1,48 @@ +import { createRoute } from '@tanstack/react-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'blocking', + loader: { + handler: () => + loaderCacheRuntime.createControlledLoad('blocking', (sequence) => + buildLoaderCachePayload('blocking', sequence, 29), + ), + staleReloadMode: 'blocking', + }, + staleTime: 0, + gcTime: 60_000, + component: BlockingPage, +}) + +function BlockingLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + void runLoaderCacheSelectorComputation(loaderData) + return null +} + +function BlockingPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.conditional.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.conditional.tsx new file mode 100644 index 0000000000..07bd7a25b7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.conditional.tsx @@ -0,0 +1,71 @@ +import { createRoute } from '@tanstack/react-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + normalizeConditionalSearch, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'conditional', + validateSearch: (search: Record) => + normalizeConditionalSearch(search), + loaderDeps: ({ search }) => ({ key: search.key }), + shouldReload: ({ location }) => { + const search = normalizeConditionalSearch( + location.search as Record, + ) + return loaderCacheRuntime.recordConditionalCheck(search.mode !== 'skip') + }, + loader: ({ deps }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('conditional') + return buildLoaderCachePayload( + 'conditional', + sequence, + deps.key.length * 31, + ) + }, + staleTime: 0, + gcTime: 60_000, + component: ConditionalPage, +}) + +function ConditionalLoaderDepsSubscriber() { + const deps = Route.useLoaderDeps({ + select: (loaderDeps) => loaderDeps.key.length, + }) + + void runLoaderCacheSelectorComputation(deps) + return null +} + +function ConditionalLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + void runLoaderCacheSelectorComputation(loaderData) + return null +} + +function ConditionalPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.evict.$bucketId.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.evict.$bucketId.tsx new file mode 100644 index 0000000000..48f0cdd122 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.evict.$bucketId.tsx @@ -0,0 +1,49 @@ +import { createRoute } from '@tanstack/react-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'evict/$bucketId', + loader: ({ params }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('evict') + return buildLoaderCachePayload( + 'evict', + sequence, + String(params.bucketId).length * 43, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: EvictPage, +}) + +function EvictLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + void runLoaderCacheSelectorComputation(loaderData) + return null +} + +function EvictPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.list.$itemId.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.list.$itemId.tsx new file mode 100644 index 0000000000..8d1d50ad04 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.list.$itemId.tsx @@ -0,0 +1,54 @@ +import { createRoute } from '@tanstack/react-router' +import { Route as listRoute } from './data.list' +import { + buildLoaderCachePayload, + createItemLoaderDeps, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => listRoute, + path: '$itemId', + loaderDeps: ({ search }) => + createItemLoaderDeps(search as Record), + loader: ({ deps, params }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('item') + return buildLoaderCachePayload( + 'item', + sequence, + String(params.itemId).length * 97 + + deps.filter.length * 13 + + deps.tag.length, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: ItemPage, +}) + +function ItemLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + void runLoaderCacheSelectorComputation(loaderData) + return null +} + +function ItemPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.list.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.list.tsx new file mode 100644 index 0000000000..3e0d822519 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.list.tsx @@ -0,0 +1,68 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + createListLoaderDeps, + loaderCacheRuntime, + normalizeListSearch, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'list', + validateSearch: (search: Record) => + normalizeListSearch(search), + loaderDeps: ({ search }) => createListLoaderDeps(search), + loader: ({ deps }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('list') + return buildLoaderCachePayload( + 'list', + sequence, + deps.page * 101 + deps.filter.length * 7 + deps.tag.length, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: ListPage, +}) + +function ListLoaderDepsSubscriber() { + const deps = Route.useLoaderDeps({ + select: (loaderDeps) => + loaderDeps.page + loaderDeps.filter.length + loaderDeps.tag.length, + }) + + void runLoaderCacheSelectorComputation(deps) + return null +} + +function ListLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + void runLoaderCacheSelectorComputation(loaderData) + return null +} + +function ListPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} + {subscriberSlots.map((slot) => ( + + ))} +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.stale.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.stale.tsx new file mode 100644 index 0000000000..65402ba2ae --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.stale.tsx @@ -0,0 +1,45 @@ +import { createRoute } from '@tanstack/react-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'stale', + loader: () => + loaderCacheRuntime.createControlledLoad('stale', (sequence) => + buildLoaderCachePayload('stale', sequence, 23), + ), + staleTime: 0, + gcTime: 60_000, + component: StalePage, +}) + +function StaleLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + void runLoaderCacheSelectorComputation(loaderData) + return null +} + +function StalePage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.tsx b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.tsx new file mode 100644 index 0000000000..d1b3e7e4fd --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/routes/data.tsx @@ -0,0 +1,57 @@ +import { Outlet, createRoute, useRouterState } from '@tanstack/react-router' +import { Route as rootRoute } from './__root' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/data', + loader: () => { + const sequence = loaderCacheRuntime.recordSyncLoad('data') + return buildLoaderCachePayload('data', sequence, 11) + }, + staleTime: 60_000, + gcTime: 60_000, + component: DataLayout, +}) + +function RouterLoadingSubscriber() { + const loading = useRouterState({ + select: (state) => + (state.isLoading ? 1 : 0) + (state.status === 'pending' ? 1 : 0), + }) + + void runLoaderCacheSelectorComputation(loading) + return null +} + +function DataLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + void runLoaderCacheSelectorComputation(loaderData) + return null +} + +function DataLayout() { + const loaderData = Route.useLoaderData() + + return ( + <> + + {subscriberSlots.map((slot) => ( + + ))} +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/src/runtime.ts b/benchmarks/client-nav/scenarios/loader-cache/react/src/runtime.ts new file mode 100644 index 0000000000..a6282ffb64 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/src/runtime.ts @@ -0,0 +1,13 @@ +import { createLoaderCacheRuntime } from '../../shared.ts' + +export { + buildLoaderCachePayload, + createItemLoaderDeps, + createListLoaderDeps, + loaderCacheSubscriberSlots as subscriberSlots, + normalizeConditionalSearch, + normalizeListSearch, + runLoaderCacheSelectorComputation, +} from '../../shared.ts' + +export const loaderCacheRuntime = createLoaderCacheRuntime() diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/tsconfig.json b/benchmarks/client-nav/scenarios/loader-cache/react/tsconfig.json new file mode 100644 index 0000000000..e5056ec745 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/react/vite.config.ts b/benchmarks/client-nav/scenarios/loader-cache/react/vite.config.ts new file mode 100644 index 0000000000..df451d260c --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav loader-cache (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/shared.ts b/benchmarks/client-nav/scenarios/loader-cache/shared.ts new file mode 100644 index 0000000000..4319e52b26 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/shared.ts @@ -0,0 +1,695 @@ +import type { NavigateOptions } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' + +export type LoaderCachePage = + | 'data' + | 'list' + | 'item' + | 'stale' + | 'blocking' + | 'conditional' + | 'evict' + +export type ControlledLoaderKind = 'stale' | 'blocking' + +export type SyncLoaderKind = 'data' | 'list' | 'item' | 'conditional' | 'evict' + +export interface ListSearch { + page: number + filter: string + tag: string +} + +export interface ListLoaderDeps extends ListSearch {} + +export interface ItemLoaderDeps { + filter: string + tag: string +} + +export interface ConditionalSearch { + key: string + mode: 'reload' | 'skip' +} + +export interface LoaderCachePayload { + route: LoaderCachePage + sequence: number + seed: number + checksum: number + size: number +} + +export interface LoaderCacheCounters { + data: number + list: number + item: number + staleStarted: number + staleCompleted: number + blockingStarted: number + blockingCompleted: number + conditional: number + conditionalChecks: number + conditionalSkips: number + evict: number +} + +export interface LoaderCacheRuntime { + reset: () => void + resolveAllControlledLoads: () => void + resolveNextControlledLoad: (kind: ControlledLoaderKind) => void + snapshot: () => LoaderCacheCounters + getPendingControlledLoadCount: (kind: ControlledLoaderKind) => number + recordSyncLoad: (kind: SyncLoaderKind) => number + recordConditionalCheck: (shouldReload: boolean) => boolean + createControlledLoad: ( + kind: ControlledLoaderKind, + build: (sequence: number) => LoaderCachePayload, + ) => Promise +} + +export const loaderCacheInitialEntry = '/data' +export const loaderCacheSubscriberSlots = Array.from( + { length: 5 }, + (_, index) => index, +) + +const random = createDeterministicRandom(6_006) + +const token = (prefix: string, index: number) => + `${prefix}-${index}-${randomSegment(random)}` + +export const loaderCacheInputs = { + itemId: token('item', 0), + listA: { + page: 1, + filter: token('filter', 0), + tag: token('tag', 0), + }, + listB: { + page: 2, + filter: token('filter', 1), + tag: token('tag', 1), + }, + conditional: { + key: token('condition', 0), + }, + evictBuckets: [0, 1, 2].map((index) => token('bucket', index)), +} as const + +const scenarioLeafRouteIds = new Set([ + '/data/list', + '/data/list/$itemId', + '/data/stale', + '/data/blocking', + '/data/conditional', +]) + +const emptyCounters = (): LoaderCacheCounters => ({ + data: 0, + list: 0, + item: 0, + staleStarted: 0, + staleCompleted: 0, + blockingStarted: 0, + blockingCompleted: 0, + conditional: 0, + conditionalChecks: 0, + conditionalSkips: 0, + evict: 0, +}) + +function normalizePositiveInteger(value: unknown, fallback: number) { + const number = Number(value) + if (!Number.isFinite(number) || number < 1) { + return fallback + } + + return Math.trunc(number) +} + +function normalizeSegment(value: unknown, fallback: string) { + if (typeof value === 'string' && value.length > 0) { + return value + } + + return fallback +} + +function checksumSeed(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 28; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function runLoaderCacheSelectorComputation(seed: number) { + return checksumSeed(seed) % 1_000_003 +} + +export function normalizeListSearch( + search: Record, +): ListSearch { + return { + page: normalizePositiveInteger(search.page, 1), + filter: normalizeSegment(search.filter, 'all'), + tag: normalizeSegment(search.tag, 'base'), + } +} + +export function createListLoaderDeps(search: ListSearch): ListLoaderDeps { + return { + page: search.page, + filter: search.filter, + tag: search.tag, + } +} + +export function createItemLoaderDeps( + search: Record, +): ItemLoaderDeps { + const listSearch = normalizeListSearch(search) + + return { + filter: listSearch.filter, + tag: listSearch.tag, + } +} + +export function normalizeConditionalSearch( + search: Record, +): ConditionalSearch { + const mode = search.mode === 'skip' ? 'skip' : 'reload' + + return { + key: normalizeSegment(search.key, 'control'), + mode, + } +} + +export function buildLoaderCachePayload( + route: LoaderCachePage, + sequence: number, + seed: number, +): LoaderCachePayload { + const checksum = checksumSeed(seed + sequence * 17 + route.length) + + return { + route, + sequence, + seed, + checksum, + size: 4, + } +} + +export function createLoaderCacheRuntime(): LoaderCacheRuntime { + let counters = emptyCounters() + const pendingControlledLoads: Record< + ControlledLoaderKind, + Array<() => void> + > = { + stale: [], + blocking: [], + } + + const resolveNextControlledLoad = (kind: ControlledLoaderKind) => { + const resolve = pendingControlledLoads[kind].shift() + if (!resolve) { + throw new Error(`No pending ${kind} loader to resolve`) + } + + resolve() + } + + const resolveAllControlledLoads = () => { + for (const kind of [ + 'stale', + 'blocking', + ] satisfies Array) { + while (pendingControlledLoads[kind].length > 0) { + resolveNextControlledLoad(kind) + } + } + } + + return { + reset() { + resolveAllControlledLoads() + counters = emptyCounters() + }, + resolveAllControlledLoads, + resolveNextControlledLoad, + snapshot() { + return { ...counters } + }, + getPendingControlledLoadCount(kind) { + return pendingControlledLoads[kind].length + }, + recordSyncLoad(kind) { + counters[kind] += 1 + return counters[kind] + }, + recordConditionalCheck(shouldReload) { + counters.conditionalChecks += 1 + if (!shouldReload) { + counters.conditionalSkips += 1 + } + + return shouldReload + }, + createControlledLoad(kind, build) { + const startKey = `${kind}Started` as const + const completeKey = `${kind}Completed` as const + counters[startKey] += 1 + const sequence = counters[startKey] + let didResolve = false + + return new Promise((resolve) => { + pendingControlledLoads[kind].push(() => { + if (didResolve) { + return + } + + didResolve = true + counters[completeKey] += 1 + resolve(build(sequence)) + }) + }) + }, + } +} + +function assertEqual(label: string, actual: number, expected: number) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`) + } +} + +function isEvictBucketMatch( + match: { routeId: unknown; params: unknown }, + buckets: ReadonlyArray, +) { + if (!String(match.routeId).endsWith('/data/evict/$bucketId')) { + return false + } + + const params = match.params as Record + return buckets.includes(String(params.bucketId)) +} + +export function createLoaderCacheWorkload( + framework: Framework, + mountTestApp: MountTestApp, + runtime: LoaderCacheRuntime, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp }) + + function hasPageMarker(page: LoaderCachePage) { + return lifecycle + .getContainer() + .querySelector(`[data-loader-cache-page="${page}"]`) + ? 1 + : 0 + } + + async function waitForPage(page: LoaderCachePage) { + await lifecycle.waitForCounter(() => hasPageMarker(page), 1, { + label: `${page} page marker`, + }) + } + + async function navigateAndWait( + options: NavigateOptions, + page: LoaderCachePage, + ) { + await lifecycle.navigate(options, { wait: 'rendered' }) + await waitForPage(page) + } + + async function navigateWithBlockingControlledLoad( + kind: ControlledLoaderKind, + options: NavigateOptions, + page: LoaderCachePage, + label: string, + ) { + const before = runtime.snapshot() + const startedKey = `${kind}Started` as const + const completedKey = `${kind}Completed` as const + const expectedStarted = before[startedKey] + 1 + const expectedCompleted = before[completedKey] + 1 + + await lifecycle.waitForRender( + async () => { + const navigation = lifecycle.getRouter().navigate(options) + + await lifecycle.waitForCounter( + () => runtime.snapshot()[startedKey], + expectedStarted, + { label: `${label} loader start` }, + ) + runtime.resolveNextControlledLoad(kind) + await navigation + await lifecycle.waitForCounter( + () => runtime.snapshot()[completedKey], + expectedCompleted, + { label: `${label} loader completion` }, + ) + }, + { label }, + ) + + await waitForPage(page) + } + + async function navigateWithBackgroundControlledLoad( + kind: ControlledLoaderKind, + options: NavigateOptions, + page: LoaderCachePage, + label: string, + ) { + const before = runtime.snapshot() + const startedKey = `${kind}Started` as const + const completedKey = `${kind}Completed` as const + const expectedStarted = before[startedKey] + 1 + const expectedCompleted = before[completedKey] + 1 + + await lifecycle.waitForRender( + async () => { + const navigation = lifecycle.getRouter().navigate(options) + await lifecycle.waitForCounter( + () => runtime.snapshot()[startedKey], + expectedStarted, + { label: `${label} loader start` }, + ) + await navigation + }, + { label: `${label} navigation` }, + ) + + await waitForPage(page) + + runtime.resolveNextControlledLoad(kind) + await lifecycle.waitForCounter( + () => runtime.snapshot()[completedKey], + expectedCompleted, + { label: `${label} loader completion` }, + ) + await waitForPage(page) + } + + function clearScenarioLeafCache() { + lifecycle.getRouter().clearCache({ + filter: (match) => scenarioLeafRouteIds.has(String(match.routeId)), + }) + } + + function clearEvictCache(buckets: ReadonlyArray) { + lifecycle.getRouter().clearCache({ + filter: (match) => isEvictBucketMatch(match, buckets), + }) + } + + async function invalidateActiveRoute(assertCounters: boolean) { + const before = runtime.snapshot() + + await lifecycle.waitForPromise(lifecycle.getRouter().invalidate(), { + label: 'router.invalidate()', + }) + await lifecycle.waitForCounter( + () => runtime.snapshot().evict, + before.evict + 1, + { + label: 'evict loader after invalidate', + }, + ) + await waitForPage('evict') + + if (assertCounters) { + const after = runtime.snapshot() + assertEqual( + 'evict invalidate reloads active route', + after.evict, + before.evict + 1, + ) + assertEqual( + 'parent data invalidates with active route', + after.data, + before.data + 1, + ) + } + } + + async function runCycle(assertCounters: boolean) { + clearScenarioLeafCache() + + await navigateAndWait( + { + to: '/data/list', + search: loaderCacheInputs.listA, + replace: true, + }, + 'list', + ) + const afterListA = runtime.snapshot() + + await navigateAndWait( + { + to: '/data/list/$itemId', + params: { itemId: loaderCacheInputs.itemId }, + search: loaderCacheInputs.listA, + replace: true, + }, + 'item', + ) + + await navigateAndWait( + { + to: '/data/list', + search: loaderCacheInputs.listA, + replace: true, + }, + 'list', + ) + + if (assertCounters) { + assertEqual( + 'fresh list cache hit', + runtime.snapshot().list, + afterListA.list, + ) + } + + await navigateAndWait( + { + to: '/data/list', + search: loaderCacheInputs.listB, + replace: true, + }, + 'list', + ) + + if (assertCounters) { + assertEqual( + 'list cache miss for deps B', + runtime.snapshot().list, + afterListA.list + 1, + ) + } + + await navigateAndWait({ to: '/data', replace: true }, 'data') + await navigateWithBlockingControlledLoad( + 'stale', + { to: '/data/stale', replace: true }, + 'stale', + 'stale cold load', + ) + await navigateAndWait({ to: '/data', replace: true }, 'data') + await navigateWithBackgroundControlledLoad( + 'stale', + { to: '/data/stale', replace: true }, + 'stale', + 'stale background reload', + ) + + await navigateAndWait({ to: '/data', replace: true }, 'data') + await navigateWithBlockingControlledLoad( + 'blocking', + { to: '/data/blocking', replace: true }, + 'blocking', + 'blocking cold load', + ) + await navigateAndWait({ to: '/data', replace: true }, 'data') + await navigateWithBlockingControlledLoad( + 'blocking', + { to: '/data/blocking', replace: true }, + 'blocking', + 'blocking stale reload', + ) + + await navigateAndWait( + { + to: '/data/conditional', + search: { key: loaderCacheInputs.conditional.key, mode: 'reload' }, + replace: true, + }, + 'conditional', + ) + const afterConditionalCold = runtime.snapshot() + + await navigateAndWait({ to: '/data', replace: true }, 'data') + await navigateAndWait( + { + to: '/data/conditional', + search: { key: loaderCacheInputs.conditional.key, mode: 'skip' }, + replace: true, + }, + 'conditional', + ) + + if (assertCounters) { + assertEqual( + 'conditional shouldReload skip', + runtime.snapshot().conditional, + afterConditionalCold.conditional, + ) + } + + await navigateAndWait({ to: '/data', replace: true }, 'data') + await navigateAndWait( + { + to: '/data/conditional', + search: { key: loaderCacheInputs.conditional.key, mode: 'reload' }, + replace: true, + }, + 'conditional', + ) + + if (assertCounters) { + assertEqual( + 'conditional shouldReload reload', + runtime.snapshot().conditional, + afterConditionalCold.conditional + 1, + ) + } + + clearEvictCache(loaderCacheInputs.evictBuckets) + + for (const bucketId of loaderCacheInputs.evictBuckets) { + await navigateAndWait( + { + to: '/data/evict/$bucketId', + params: { bucketId }, + replace: true, + }, + 'evict', + ) + } + + const afterEvictWarm = runtime.snapshot() + const evictedBucket = loaderCacheInputs.evictBuckets[0]! + const retainedBucket = loaderCacheInputs.evictBuckets[1]! + + clearEvictCache([evictedBucket]) + await navigateAndWait( + { + to: '/data/evict/$bucketId', + params: { bucketId: evictedBucket }, + replace: true, + }, + 'evict', + ) + const afterEvictedReload = runtime.snapshot() + + if (assertCounters) { + assertEqual( + 'filtered clear evicts one bucket', + afterEvictedReload.evict, + afterEvictWarm.evict + 1, + ) + } + + await navigateAndWait( + { + to: '/data/evict/$bucketId', + params: { bucketId: retainedBucket }, + replace: true, + }, + 'evict', + ) + + if (assertCounters) { + assertEqual( + 'filtered clear retains other bucket', + runtime.snapshot().evict, + afterEvictedReload.evict, + ) + } + + await invalidateActiveRoute(assertCounters) + } + + async function before() { + runtime.reset() + await lifecycle.before() + await waitForPage('data') + } + + async function after() { + runtime.resolveAllControlledLoads() + await lifecycle.after() + } + + return { + name: `client loader cache loop (${framework})`, + before, + async run() { + await runCycle(false) + }, + async sanity() { + await before() + + try { + await runCycle(true) + + const counters = runtime.snapshot() + assertEqual('parent data loaders', counters.data, 2) + assertEqual('list loaders', counters.list, 2) + assertEqual('item loaders', counters.item, 1) + assertEqual('stale loader starts', counters.staleStarted, 2) + assertEqual('stale loader completions', counters.staleCompleted, 2) + assertEqual('blocking loader starts', counters.blockingStarted, 2) + assertEqual( + 'blocking loader completions', + counters.blockingCompleted, + 2, + ) + assertEqual('conditional loaders', counters.conditional, 2) + assertEqual('evict loaders', counters.evict, 5) + + if (counters.conditionalSkips < 1) { + throw new Error( + 'conditional shouldReload did not record a skipped reload', + ) + } + } finally { + await after() + } + }, + after, + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/project.json b/benchmarks/client-nav/scenarios/loader-cache/solid/project.json new file mode 100644 index 0000000000..54586a5c47 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-loader-cache-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/setup.ts b/benchmarks/client-nav/scenarios/loader-cache/solid/setup.ts new file mode 100644 index 0000000000..da88f0fa61 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createLoaderCacheWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { loaderCacheRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createLoaderCacheWorkload( + 'solid', + mountTestApp, + loaderCacheRuntime, +) diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/loader-cache/solid/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/loader-cache/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/app.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/app.tsx new file mode 100644 index 0000000000..992d36518c --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/app.tsx @@ -0,0 +1,23 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { getRouter } from './router' + +export { loaderCacheRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routeTree.tsx new file mode 100644 index 0000000000..367edcafa2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routeTree.tsx @@ -0,0 +1,18 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as dataRoute } from './routes/data' +import { Route as listRoute } from './routes/data.list' +import { Route as itemRoute } from './routes/data.list.$itemId' +import { Route as staleRoute } from './routes/data.stale' +import { Route as blockingRoute } from './routes/data.blocking' +import { Route as conditionalRoute } from './routes/data.conditional' +import { Route as evictRoute } from './routes/data.evict.$bucketId' + +export const routeTree = rootRoute.addChildren([ + dataRoute.addChildren([ + listRoute.addChildren([itemRoute]), + staleRoute, + blockingRoute, + conditionalRoute, + evictRoute, + ]), +]) diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/router.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/router.tsx new file mode 100644 index 0000000000..b969f9db28 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/router.tsx @@ -0,0 +1,20 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { loaderCacheInitialEntry } from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [loaderCacheInitialEntry], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..cb8d5a688d --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.blocking.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.blocking.tsx new file mode 100644 index 0000000000..4462bf9395 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.blocking.tsx @@ -0,0 +1,49 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { Route as dataRoute } from './data' +import { + PerfValue, + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'blocking', + loader: { + handler: () => + loaderCacheRuntime.createControlledLoad('blocking', (sequence) => + buildLoaderCachePayload('blocking', sequence, 29), + ), + staleReloadMode: 'blocking', + }, + staleTime: 0, + gcTime: 60_000, + component: BlockingPage, +}) + +function BlockingLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return ( + runLoaderCacheSelectorComputation(loaderData())} /> + ) +} + +function BlockingPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {() => } +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.conditional.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.conditional.tsx new file mode 100644 index 0000000000..5f2843bbd2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.conditional.tsx @@ -0,0 +1,73 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { Route as dataRoute } from './data' +import { + PerfValue, + buildLoaderCachePayload, + loaderCacheRuntime, + normalizeConditionalSearch, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'conditional', + validateSearch: (search: Record) => + normalizeConditionalSearch(search), + loaderDeps: ({ search }) => ({ key: search.key }), + shouldReload: ({ location }) => { + const search = normalizeConditionalSearch( + location.search as Record, + ) + return loaderCacheRuntime.recordConditionalCheck(search.mode !== 'skip') + }, + loader: ({ deps }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('conditional') + return buildLoaderCachePayload( + 'conditional', + sequence, + deps.key.length * 31, + ) + }, + staleTime: 0, + gcTime: 60_000, + component: ConditionalPage, +}) + +function ConditionalLoaderDepsSubscriber() { + const deps = Route.useLoaderDeps({ + select: (loaderDeps) => loaderDeps.key.length, + }) + + return runLoaderCacheSelectorComputation(deps())} /> +} + +function ConditionalLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return ( + runLoaderCacheSelectorComputation(loaderData())} /> + ) +} + +function ConditionalPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + + {() => } + + + {() => } + +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.evict.$bucketId.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.evict.$bucketId.tsx new file mode 100644 index 0000000000..5ae84f3c93 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.evict.$bucketId.tsx @@ -0,0 +1,50 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { Route as dataRoute } from './data' +import { + PerfValue, + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'evict/$bucketId', + loader: ({ params }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('evict') + return buildLoaderCachePayload( + 'evict', + sequence, + String(params.bucketId).length * 43, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: EvictPage, +}) + +function EvictLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return ( + runLoaderCacheSelectorComputation(loaderData())} /> + ) +} + +function EvictPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {() => } +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.list.$itemId.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.list.$itemId.tsx new file mode 100644 index 0000000000..3d0f8430de --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.list.$itemId.tsx @@ -0,0 +1,55 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { Route as listRoute } from './data.list' +import { + PerfValue, + buildLoaderCachePayload, + createItemLoaderDeps, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => listRoute, + path: '$itemId', + loaderDeps: ({ search }) => + createItemLoaderDeps(search as Record), + loader: ({ deps, params }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('item') + return buildLoaderCachePayload( + 'item', + sequence, + String(params.itemId).length * 97 + + deps.filter.length * 13 + + deps.tag.length, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: ItemPage, +}) + +function ItemLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return ( + runLoaderCacheSelectorComputation(loaderData())} /> + ) +} + +function ItemPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {() => } +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.list.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.list.tsx new file mode 100644 index 0000000000..d247677354 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.list.tsx @@ -0,0 +1,66 @@ +import { For } from 'solid-js' +import { Outlet, createRoute } from '@tanstack/solid-router' +import { Route as dataRoute } from './data' +import { + PerfValue, + buildLoaderCachePayload, + createListLoaderDeps, + loaderCacheRuntime, + normalizeListSearch, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'list', + validateSearch: (search: Record) => + normalizeListSearch(search), + loaderDeps: ({ search }) => createListLoaderDeps(search), + loader: ({ deps }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('list') + return buildLoaderCachePayload( + 'list', + sequence, + deps.page * 101 + deps.filter.length * 7 + deps.tag.length, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: ListPage, +}) + +function ListLoaderDepsSubscriber() { + const deps = Route.useLoaderDeps({ + select: (loaderDeps) => + loaderDeps.page + loaderDeps.filter.length + loaderDeps.tag.length, + }) + + return runLoaderCacheSelectorComputation(deps())} /> +} + +function ListLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return ( + runLoaderCacheSelectorComputation(loaderData())} /> + ) +} + +function ListPage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {() => } + {() => } +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.stale.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.stale.tsx new file mode 100644 index 0000000000..10e99745ae --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.stale.tsx @@ -0,0 +1,46 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { Route as dataRoute } from './data' +import { + PerfValue, + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'stale', + loader: () => + loaderCacheRuntime.createControlledLoad('stale', (sequence) => + buildLoaderCachePayload('stale', sequence, 23), + ), + staleTime: 0, + gcTime: 60_000, + component: StalePage, +}) + +function StaleLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return ( + runLoaderCacheSelectorComputation(loaderData())} /> + ) +} + +function StalePage() { + const loaderData = Route.useLoaderData() + + return ( + <> + {() => } +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.tsx b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.tsx new file mode 100644 index 0000000000..e444961b27 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/routes/data.tsx @@ -0,0 +1,59 @@ +import { For } from 'solid-js' +import { Outlet, createRoute, useRouterState } from '@tanstack/solid-router' +import { Route as rootRoute } from './__root' +import { + PerfValue, + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/data', + loader: () => { + const sequence = loaderCacheRuntime.recordSyncLoad('data') + return buildLoaderCachePayload('data', sequence, 11) + }, + staleTime: 60_000, + gcTime: 60_000, + component: DataLayout, +}) + +function RouterLoadingSubscriber() { + const loading = useRouterState({ + select: (state) => + (state.isLoading ? 1 : 0) + (state.status === 'pending' ? 1 : 0), + }) + + return ( + runLoaderCacheSelectorComputation(loading())} /> + ) +} + +function DataLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return ( + runLoaderCacheSelectorComputation(loaderData())} /> + ) +} + +function DataLayout() { + const loaderData = Route.useLoaderData() + + return ( + <> + + {() => } +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/src/runtime.ts b/benchmarks/client-nav/scenarios/loader-cache/solid/src/runtime.ts new file mode 100644 index 0000000000..05c9a92431 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/src/runtime.ts @@ -0,0 +1,22 @@ +import { createRenderEffect } from 'solid-js' +import { createLoaderCacheRuntime } from '../../shared.ts' + +export { + buildLoaderCachePayload, + createItemLoaderDeps, + createListLoaderDeps, + loaderCacheSubscriberSlots as subscriberSlots, + normalizeConditionalSearch, + normalizeListSearch, + runLoaderCacheSelectorComputation, +} from '../../shared.ts' + +export const loaderCacheRuntime = createLoaderCacheRuntime() + +export function PerfValue(props: { value: () => number }) { + createRenderEffect(() => { + void props.value() + }) + + return null +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/tsconfig.json b/benchmarks/client-nav/scenarios/loader-cache/solid/tsconfig.json new file mode 100644 index 0000000000..b549cd9fe8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/solid/vite.config.ts b/benchmarks/client-nav/scenarios/loader-cache/solid/vite.config.ts new file mode 100644 index 0000000000..76f5598b39 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/solid/vite.config.ts @@ -0,0 +1,42 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav loader-cache (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/project.json b/benchmarks/client-nav/scenarios/loader-cache/vue/project.json new file mode 100644 index 0000000000..8d7b7c23bf --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-loader-cache-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/setup.ts b/benchmarks/client-nav/scenarios/loader-cache/vue/setup.ts new file mode 100644 index 0000000000..de50273b3c --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/setup.ts @@ -0,0 +1,14 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createLoaderCacheWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { loaderCacheRuntime, mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createLoaderCacheWorkload( + 'vue', + mountTestApp, + loaderCacheRuntime, +) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/loader-cache/vue/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/loader-cache/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/app.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/app.tsx new file mode 100644 index 0000000000..a880a37dc5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/app.tsx @@ -0,0 +1,27 @@ +import { createApp } from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { getRouter } from './router' + +export { loaderCacheRuntime } from './runtime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routeTree.tsx new file mode 100644 index 0000000000..367edcafa2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routeTree.tsx @@ -0,0 +1,18 @@ +import { Route as rootRoute } from './routes/__root' +import { Route as dataRoute } from './routes/data' +import { Route as listRoute } from './routes/data.list' +import { Route as itemRoute } from './routes/data.list.$itemId' +import { Route as staleRoute } from './routes/data.stale' +import { Route as blockingRoute } from './routes/data.blocking' +import { Route as conditionalRoute } from './routes/data.conditional' +import { Route as evictRoute } from './routes/data.evict.$bucketId' + +export const routeTree = rootRoute.addChildren([ + dataRoute.addChildren([ + listRoute.addChildren([itemRoute]), + staleRoute, + blockingRoute, + conditionalRoute, + evictRoute, + ]), +]) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/router.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/router.tsx new file mode 100644 index 0000000000..dc2ad06ad4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/router.tsx @@ -0,0 +1,20 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { loaderCacheInitialEntry } from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [loaderCacheInitialEntry], + }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..91296e6f84 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.blocking.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.blocking.tsx new file mode 100644 index 0000000000..159778afe1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.blocking.tsx @@ -0,0 +1,55 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +const BlockingLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return () => { + void runLoaderCacheSelectorComputation(loaderData.value) + return null + } + }, +}) + +const BlockingPage = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'blocking', + loader: { + handler: () => + loaderCacheRuntime.createControlledLoad('blocking', (sequence) => + buildLoaderCachePayload('blocking', sequence, 29), + ), + staleReloadMode: 'blocking', + }, + staleTime: 0, + gcTime: 60_000, + component: BlockingPage, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.conditional.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.conditional.tsx new file mode 100644 index 0000000000..2560c809b8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.conditional.tsx @@ -0,0 +1,82 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + normalizeConditionalSearch, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +const ConditionalLoaderDepsSubscriber = Vue.defineComponent({ + setup() { + const deps = Route.useLoaderDeps({ + select: (loaderDeps) => loaderDeps.key.length, + }) + + return () => { + void runLoaderCacheSelectorComputation(deps.value) + return null + } + }, +}) + +const ConditionalLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return () => { + void runLoaderCacheSelectorComputation(loaderData.value) + return null + } + }, +}) + +const ConditionalPage = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'conditional', + validateSearch: (search: Record) => + normalizeConditionalSearch(search), + loaderDeps: ({ search }) => ({ key: search.key }), + shouldReload: ({ location }) => { + const search = normalizeConditionalSearch( + location.search as Record, + ) + return loaderCacheRuntime.recordConditionalCheck(search.mode !== 'skip') + }, + loader: ({ deps }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('conditional') + return buildLoaderCachePayload( + 'conditional', + sequence, + deps.key.length * 31, + ) + }, + staleTime: 0, + gcTime: 60_000, + component: ConditionalPage, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.evict.$bucketId.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.evict.$bucketId.tsx new file mode 100644 index 0000000000..cf1f122ae9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.evict.$bucketId.tsx @@ -0,0 +1,56 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +const EvictLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return () => { + void runLoaderCacheSelectorComputation(loaderData.value) + return null + } + }, +}) + +const EvictPage = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'evict/$bucketId', + loader: ({ params }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('evict') + return buildLoaderCachePayload( + 'evict', + sequence, + String(params.bucketId).length * 43, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: EvictPage, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.list.$itemId.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.list.$itemId.tsx new file mode 100644 index 0000000000..c210b71e5d --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.list.$itemId.tsx @@ -0,0 +1,61 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { Route as listRoute } from './data.list' +import { + buildLoaderCachePayload, + createItemLoaderDeps, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +const ItemLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return () => { + void runLoaderCacheSelectorComputation(loaderData.value) + return null + } + }, +}) + +const ItemPage = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => listRoute, + path: '$itemId', + loaderDeps: ({ search }) => + createItemLoaderDeps(search as Record), + loader: ({ deps, params }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('item') + return buildLoaderCachePayload( + 'item', + sequence, + String(params.itemId).length * 97 + + deps.filter.length * 13 + + deps.tag.length, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: ItemPage, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.list.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.list.tsx new file mode 100644 index 0000000000..c3b0b741f6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.list.tsx @@ -0,0 +1,79 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + createListLoaderDeps, + loaderCacheRuntime, + normalizeListSearch, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +const ListLoaderDepsSubscriber = Vue.defineComponent({ + setup() { + const deps = Route.useLoaderDeps({ + select: (loaderDeps) => + loaderDeps.page + loaderDeps.filter.length + loaderDeps.tag.length, + }) + + return () => { + void runLoaderCacheSelectorComputation(deps.value) + return null + } + }, +}) + +const ListLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return () => { + void runLoaderCacheSelectorComputation(loaderData.value) + return null + } + }, +}) + +const ListPage = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} + {subscriberSlots.map((slot) => ( + + ))} +
+ + + ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'list', + validateSearch: (search: Record) => + normalizeListSearch(search), + loaderDeps: ({ search }) => createListLoaderDeps(search), + loader: ({ deps }) => { + const sequence = loaderCacheRuntime.recordSyncLoad('list') + return buildLoaderCachePayload( + 'list', + sequence, + deps.page * 101 + deps.filter.length * 7 + deps.tag.length, + ) + }, + staleTime: 60_000, + gcTime: 60_000, + component: ListPage, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.stale.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.stale.tsx new file mode 100644 index 0000000000..e126b6732b --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.stale.tsx @@ -0,0 +1,52 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { Route as dataRoute } from './data' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +const StaleLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return () => { + void runLoaderCacheSelectorComputation(loaderData.value) + return null + } + }, +}) + +const StalePage = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( + <> + {subscriberSlots.map((slot) => ( + + ))} +
+ + ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => dataRoute, + path: 'stale', + loader: () => + loaderCacheRuntime.createControlledLoad('stale', (sequence) => + buildLoaderCachePayload('stale', sequence, 23), + ), + staleTime: 0, + gcTime: 60_000, + component: StalePage, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.tsx b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.tsx new file mode 100644 index 0000000000..bd0442ca56 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/routes/data.tsx @@ -0,0 +1,68 @@ +import * as Vue from 'vue' +import { Outlet, createRoute, useRouterState } from '@tanstack/vue-router' +import { Route as rootRoute } from './__root' +import { + buildLoaderCachePayload, + loaderCacheRuntime, + runLoaderCacheSelectorComputation, + subscriberSlots, +} from '../runtime' + +const RouterLoadingSubscriber = Vue.defineComponent({ + setup() { + const loading = useRouterState({ + select: (state) => + (state.isLoading ? 1 : 0) + (state.status === 'pending' ? 1 : 0), + }) + + return () => { + void runLoaderCacheSelectorComputation(loading.value) + return null + } + }, +}) + +const DataLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: (data) => data.checksum + data.sequence, + }) + + return () => { + void runLoaderCacheSelectorComputation(loaderData.value) + return null + } + }, +}) + +const DataLayout = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + + return () => ( + <> + + {subscriberSlots.map((slot) => ( + + ))} +
+ + + ) + }, +}) + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: '/data', + loader: () => { + const sequence = loaderCacheRuntime.recordSyncLoad('data') + return buildLoaderCachePayload('data', sequence, 11) + }, + staleTime: 60_000, + gcTime: 60_000, + component: DataLayout, +}) diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/src/runtime.ts b/benchmarks/client-nav/scenarios/loader-cache/vue/src/runtime.ts new file mode 100644 index 0000000000..a6282ffb64 --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/src/runtime.ts @@ -0,0 +1,13 @@ +import { createLoaderCacheRuntime } from '../../shared.ts' + +export { + buildLoaderCachePayload, + createItemLoaderDeps, + createListLoaderDeps, + loaderCacheSubscriberSlots as subscriberSlots, + normalizeConditionalSearch, + normalizeListSearch, + runLoaderCacheSelectorComputation, +} from '../../shared.ts' + +export const loaderCacheRuntime = createLoaderCacheRuntime() diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/tsconfig.json b/benchmarks/client-nav/scenarios/loader-cache/vue/tsconfig.json new file mode 100644 index 0000000000..24bdb3e3cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/loader-cache/vue/vite.config.ts b/benchmarks/client-nav/scenarios/loader-cache/vue/vite.config.ts new file mode 100644 index 0000000000..df13e00ccc --- /dev/null +++ b/benchmarks/client-nav/scenarios/loader-cache/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav loader-cache (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/project.json b/benchmarks/client-nav/scenarios/location-building-links/react/project.json new file mode 100644 index 0000000000..8ea7cec6b6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-location-building-links-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/setup.ts b/benchmarks/client-nav/scenarios/location-building-links/react/setup.ts new file mode 100644 index 0000000000..de347dbc26 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createLocationBuildingLinksWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createLocationBuildingLinksWorkload( + 'react', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/speed.bench.ts b/benchmarks/client-nav/scenarios/location-building-links/react/speed.bench.ts new file mode 100644 index 0000000000..ba98663dc2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav location-building-links', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/speed.flame.ts b/benchmarks/client-nav/scenarios/location-building-links/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/app.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/app.tsx new file mode 100644 index 0000000000..439c4ed204 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/app.tsx @@ -0,0 +1,29 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { patchMissingScrollToGlobal } from '../../shared.ts' +import { getRouter } from './router' + +export function mountTestApp(container: HTMLDivElement) { + const restoreScrollTo = patchMissingScrollToGlobal() + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + try { + reactRoot.unmount() + } finally { + restoreScrollTo() + } + }, + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/link-panel.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/link-panel.tsx new file mode 100644 index 0000000000..71326aacea --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/link-panel.tsx @@ -0,0 +1,111 @@ +import { + Link, + useMatchRoute, + useRouter, + useRouterState, +} from '@tanstack/react-router' +import { + buildLocationDescriptors, + createActiveOptions, + createLinkLabel, + createLinkOptions, + createLocationState, + createMatchOptions, + linkDescriptors, + matchDescriptors, + readBuiltPublicHref, + type BuiltLocationSnapshot, + type LinkDescriptor, + type MatchDescriptor, +} from '../../shared.ts' + +function createActiveProps(descriptor: LinkDescriptor) { + if (descriptor.styleVariant % 2 === 0) { + return { className: 'active-link' } + } + + return () => ({ + className: 'active-link active-link-fn', + 'data-active-key': descriptor.key, + }) +} + +function createInactiveProps(descriptor: LinkDescriptor) { + if (descriptor.styleVariant % 2 === 0) { + return { className: 'inactive-link' } + } + + return () => ({ + className: 'inactive-link inactive-link-fn', + 'data-inactive-key': descriptor.key, + }) +} + +function PanelLink({ descriptor }: { descriptor: LinkDescriptor }) { + return ( + + {createLinkLabel(descriptor)} + + ) +} + +function MatchProbe({ descriptor }: { descriptor: MatchDescriptor }) { + const matchRoute = useMatchRoute() + const params = matchRoute(createMatchOptions(descriptor) as any) + + return ( + + {params ? 'matched' : 'unmatched'} + + ) +} + +function BuildLocationProbe({ descriptor }: { descriptor: LinkDescriptor }) { + const router = useRouter() + const locationHref = useRouterState({ + select: (state) => state.location.href, + }) + void locationHref + + const builtHref = readBuiltPublicHref( + router.buildLocation({ + _fromLocation: router.state.location, + ...createLinkOptions(descriptor), + state: createLocationState(descriptor), + } as any) as BuiltLocationSnapshot, + ) + + return ( + + {builtHref} + + ) +} + +export function LinkPanel() { + return ( +
+ {linkDescriptors.map((descriptor) => ( + + ))} + {matchDescriptors.map((descriptor) => ( + + ))} + {buildLocationDescriptors.map((descriptor) => ( + + ))} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/location-building-links/react/src/routeTree.gen.ts new file mode 100644 index 0000000000..aa9f920cb7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/routeTree.gen.ts @@ -0,0 +1,174 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as DashboardProjectsProjectIdTasksTaskIdRouteImport } from './routes/dashboard.projects.$projectId.tasks.$taskId' +import { Route as DashboardProjectsProjectIdRouteImport } from './routes/dashboard.projects.$projectId' +import { Route as DashboardReportsReportIdRouteImport } from './routes/dashboard.reports.$reportId' +import { Route as DashboardRouteImport } from './routes/dashboard' +import { Route as SettingsTabRouteImport } from './routes/settings.{-$tab}' + +const DashboardRoute = DashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsTabRoute = SettingsTabRouteImport.update({ + id: '/settings/{-$tab}', + path: '/settings/{-$tab}', + getParentRoute: () => rootRouteImport, +} as any) +const DashboardProjectsProjectIdRoute = + DashboardProjectsProjectIdRouteImport.update({ + id: '/projects/$projectId', + path: '/projects/$projectId', + getParentRoute: () => DashboardRoute, + } as any) +const DashboardReportsReportIdRoute = DashboardReportsReportIdRouteImport.update( + { + id: '/reports/$reportId', + path: '/reports/$reportId', + getParentRoute: () => DashboardRoute, + } as any, +) +const DashboardProjectsProjectIdTasksTaskIdRoute = + DashboardProjectsProjectIdTasksTaskIdRouteImport.update({ + id: '/tasks/$taskId', + path: '/tasks/$taskId', + getParentRoute: () => DashboardProjectsProjectIdRoute, + } as any) + +export interface FileRoutesByFullPath { + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesByTo { + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + fileRoutesByTo: FileRoutesByTo + to: + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + id: + | '__root__' + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + DashboardRoute: typeof DashboardRouteWithChildren + SettingsTabRoute: typeof SettingsTabRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardRouteImport + parentRoute: typeof rootRouteImport + } + '/settings/{-$tab}': { + id: '/settings/{-$tab}' + path: '/settings/{-$tab}' + fullPath: '/settings/{-$tab}' + preLoaderRoute: typeof SettingsTabRouteImport + parentRoute: typeof rootRouteImport + } + '/dashboard/projects/$projectId': { + id: '/dashboard/projects/$projectId' + path: '/projects/$projectId' + fullPath: '/dashboard/projects/$projectId' + preLoaderRoute: typeof DashboardProjectsProjectIdRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/reports/$reportId': { + id: '/dashboard/reports/$reportId' + path: '/reports/$reportId' + fullPath: '/dashboard/reports/$reportId' + preLoaderRoute: typeof DashboardReportsReportIdRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/projects/$projectId/tasks/$taskId': { + id: '/dashboard/projects/$projectId/tasks/$taskId' + path: '/tasks/$taskId' + fullPath: '/dashboard/projects/$projectId/tasks/$taskId' + preLoaderRoute: typeof DashboardProjectsProjectIdTasksTaskIdRouteImport + parentRoute: typeof DashboardProjectsProjectIdRoute + } + } +} + +interface DashboardProjectsProjectIdRouteChildren { + DashboardProjectsProjectIdTasksTaskIdRoute: typeof DashboardProjectsProjectIdTasksTaskIdRoute +} + +const DashboardProjectsProjectIdRouteChildren: DashboardProjectsProjectIdRouteChildren = + { + DashboardProjectsProjectIdTasksTaskIdRoute: + DashboardProjectsProjectIdTasksTaskIdRoute, + } + +const DashboardProjectsProjectIdRouteWithChildren = + DashboardProjectsProjectIdRoute._addFileChildren( + DashboardProjectsProjectIdRouteChildren, + ) + +interface DashboardRouteChildren { + DashboardProjectsProjectIdRoute: typeof DashboardProjectsProjectIdRouteWithChildren + DashboardReportsReportIdRoute: typeof DashboardReportsReportIdRoute +} + +const DashboardRouteChildren: DashboardRouteChildren = { + DashboardProjectsProjectIdRoute: DashboardProjectsProjectIdRouteWithChildren, + DashboardReportsReportIdRoute: DashboardReportsReportIdRoute, +} + +const DashboardRouteWithChildren = DashboardRoute._addFileChildren( + DashboardRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + DashboardRoute: DashboardRouteWithChildren, + SettingsTabRoute: SettingsTabRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/router.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/router.tsx new file mode 100644 index 0000000000..dbfb79f744 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { initialLocation } from '../../shared.ts' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/__root.tsx new file mode 100644 index 0000000000..e02dfe7400 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/__root.tsx @@ -0,0 +1,19 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' +import { normalizeRootSearch } from '../../../shared.ts' +import { LinkPanel } from '../link-panel' + +export const Route = createRootRoute({ + validateSearch: normalizeRootSearch, + component: RootComponent, +}) + +function RootComponent() { + return ( + <> + +
+ +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx new file mode 100644 index 0000000000..a5d4f3db9e --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/dashboard/projects/$projectId/tasks/$taskId', +)({ + component: TaskPage, +}) + +function TaskPage() { + const params = Route.useParams() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.projects.$projectId.tsx new file mode 100644 index 0000000000..3139fb19b0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.projects.$projectId.tsx @@ -0,0 +1,16 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard/projects/$projectId')({ + component: ProjectPage, +}) + +function ProjectPage() { + const params = Route.useParams() + + return ( + <> +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.reports.$reportId.tsx new file mode 100644 index 0000000000..d5f50af25e --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.reports.$reportId.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard/reports/$reportId')({ + component: ReportPage, +}) + +function ReportPage() { + const params = Route.useParams() + + return
+} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.tsx new file mode 100644 index 0000000000..de0b3ac3bb --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/dashboard.tsx @@ -0,0 +1,9 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + component: DashboardPage, +}) + +function DashboardPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/settings.{-$tab}.tsx b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/settings.{-$tab}.tsx new file mode 100644 index 0000000000..1a898562f1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/src/routes/settings.{-$tab}.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/settings/{-$tab}')({ + component: SettingsPage, +}) + +function SettingsPage() { + const params = Route.useParams() + + return
+} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/tsconfig.json b/benchmarks/client-nav/scenarios/location-building-links/react/tsconfig.json new file mode 100644 index 0000000000..e5056ec745 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/react/vite.config.ts b/benchmarks/client-nav/scenarios/location-building-links/react/vite.config.ts new file mode 100644 index 0000000000..38722cbb22 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav location-building-links (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/shared.ts b/benchmarks/client-nav/scenarios/location-building-links/shared.ts new file mode 100644 index 0000000000..240fd70c72 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/shared.ts @@ -0,0 +1,824 @@ +import type { NavigateOptions } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' +import { + createClientNavLifecycle, + warnClientNavDevMode, + type Framework, + type MountTestApp, +} from '#client-nav/lifecycle' + +export type RootSearch = { + page: number + filter: string + view: string + preserve?: string +} + +export type LinkKind = + | 'project' + | 'task' + | 'relative-task' + | 'project-updater' + | 'report' + | 'settings' + +export type SearchVariant = 'object' | 'updater' | 'preserve' | 'none' + +export type LinkDescriptor = { + index: number + key: string + kind: LinkKind + projectId: string + siblingProjectId: string + taskId: string + reportId: string + tab: string | undefined + hash: string + searchFilter: string + searchVariant: SearchVariant + activeVariant: number + styleVariant: number +} + +export type MatchDescriptor = { + index: number + key: string + kind: Exclude + projectId: string + taskId: string + reportId: string + tab: string | undefined + includeSearch: boolean + fuzzy: boolean +} + +export type BuiltLocationSnapshot = { + publicHref: string + maskedLocation?: { + publicHref: string + } +} + +type ScrollGlobal = typeof globalThis & { + scrollTo?: typeof window.scrollTo +} + +type NavigationStep = { + label: string + options: Record +} + +const linkKinds = [ + 'project', + 'task', + 'relative-task', + 'project-updater', + 'report', + 'settings', +] as const satisfies ReadonlyArray + +const searchVariants = [ + 'object', + 'updater', + 'preserve', + 'none', +] as const satisfies ReadonlyArray + +const scenarioRandom = createDeterministicRandom(0x510cafe) + +const linkPanelExpectedCount = 240 +export const matchProbeExpectedCount = 64 +export const buildLocationExpectedCount = 24 +export const navigationStepsPerCycle = 6 +export const navigationCyclesPerRun = 4 +export const navigationsPerBenchRun = + navigationStepsPerCycle * navigationCyclesPerRun + +export const settingsTabs = ['overview', 'billing', 'security', 'members'] + +function createSegments(prefix: string, count: number) { + const values: Array = [] + + for (let index = 0; index < count; index++) { + values.push(`${prefix}-${index}-${randomSegment(scenarioRandom)}`) + } + + return values +} + +export const projectIds = createSegments('project', 72) +export const taskIds = createSegments('task', 72) +export const reportIds = createSegments('report', 72) + +export const initialProjectId = projectIds[0]! +export const initialLocation = `/dashboard/projects/${initialProjectId}?page=1&filter=initial&view=summary` + +function normalizePositiveInteger(value: unknown, fallback: number) { + const numberValue = Number(value) + + if (Number.isFinite(numberValue) && numberValue > 0) { + return Math.trunc(numberValue) + } + + return fallback +} + +function normalizeString(value: unknown, fallback: string) { + if (typeof value === 'string' && value.length > 0) { + return value + } + + return fallback +} + +function normalizeOptionalString(value: unknown) { + if (typeof value === 'string' && value.length > 0) { + return value + } + + return undefined +} + +export function normalizeRootSearch( + search: Record, +): RootSearch { + return { + page: normalizePositiveInteger(search.page, 1), + filter: normalizeString(search.filter, 'all'), + view: normalizeString(search.view, 'summary'), + preserve: normalizeOptionalString(search.preserve), + } +} + +function createLinkDescriptor(index: number): LinkDescriptor { + const kind = linkKinds[index % linkKinds.length]! + const projectId = + index === 0 ? initialProjectId : projectIds[index % projectIds.length]! + const siblingProjectId = projectIds[(index + 11) % projectIds.length]! + const taskId = taskIds[(index * 3 + 5) % taskIds.length]! + const reportId = reportIds[(index * 5 + 7) % reportIds.length]! + const tab = + index === 5 + ? settingsTabs[0]! + : index % 12 === 5 + ? undefined + : settingsTabs[index % settingsTabs.length] + + return { + index, + key: `location-link-${index}`, + kind, + projectId, + siblingProjectId, + taskId, + reportId, + tab, + hash: `section-${index % 17}`, + searchFilter: `filter-${index % 13}`, + searchVariant: + index === 5 ? 'preserve' : searchVariants[index % searchVariants.length]!, + activeVariant: index === 5 ? 0 : index % 3, + styleVariant: index % 4, + } +} + +function createMatchDescriptor(index: number): MatchDescriptor { + const kindIndex = index % 4 + const kind = ( + kindIndex === 0 + ? 'project' + : kindIndex === 1 + ? 'task' + : kindIndex === 2 + ? 'report' + : 'settings' + ) satisfies MatchDescriptor['kind'] + + return { + index, + key: `location-match-${index}`, + kind, + projectId: projectIds[(index * 7) % projectIds.length]!, + taskId: taskIds[(index * 11) % taskIds.length]!, + reportId: reportIds[(index * 13) % reportIds.length]!, + tab: + index % 8 === 0 ? undefined : settingsTabs[index % settingsTabs.length], + includeSearch: index % 3 === 0, + fuzzy: index % 2 === 0, + } +} + +export const linkDescriptors = Array.from( + { length: linkPanelExpectedCount }, + (_, index) => createLinkDescriptor(index), +) + +export const buildLocationDescriptors = linkDescriptors.slice( + 0, + buildLocationExpectedCount, +) + +export const hrefComparisonKeys = buildLocationDescriptors + .slice(0, 12) + .map((descriptor) => descriptor.key) + +export const matchDescriptors = Array.from( + { length: matchProbeExpectedCount }, + (_, index) => createMatchDescriptor(index), +) + +export function createSearchObject(descriptor: LinkDescriptor): RootSearch { + return { + page: (descriptor.index % 11) + 1, + filter: descriptor.searchFilter, + view: descriptor.index % 2 === 0 ? 'grid' : 'list', + preserve: descriptor.key, + } +} + +export function createSearchUpdater(descriptor: LinkDescriptor) { + return (prev: RootSearch): RootSearch => { + const normalized = normalizeRootSearch( + prev as unknown as Record, + ) + + return { + page: normalized.page + ((descriptor.index % 3) + 1), + filter: descriptor.searchFilter, + view: normalized.view === 'grid' ? 'detail' : 'grid', + preserve: descriptor.key, + } + } +} + +function createSearchValue(descriptor: LinkDescriptor) { + if (descriptor.searchVariant === 'object') { + return createSearchObject(descriptor) + } + + if (descriptor.searchVariant === 'updater') { + return createSearchUpdater(descriptor) + } + + if (descriptor.searchVariant === 'preserve') { + return true + } + + return undefined +} + +function withSearch( + options: Record, + descriptor: LinkDescriptor, +) { + const search = createSearchValue(descriptor) + + if (search !== undefined) { + return { + ...options, + search, + } + } + + return options +} + +export function createLocationState(descriptor: LinkDescriptor) { + return { + scenario: 'location-building-links', + key: descriptor.key, + index: descriptor.index, + } +} + +export function createActiveOptions(descriptor: LinkDescriptor) { + if (descriptor.activeVariant === 0) { + return { + exact: true, + includeSearch: false, + } + } + + if (descriptor.activeVariant === 1) { + return { + includeSearch: true, + } + } + + return { + includeSearch: true, + includeHash: true, + } +} + +export function createLinkOptions(descriptor: LinkDescriptor) { + if (descriptor.kind === 'project') { + return withSearch( + { + to: '/dashboard/projects/$projectId', + params: { projectId: descriptor.projectId }, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + descriptor, + ) + } + + if (descriptor.kind === 'task') { + return withSearch( + { + to: '/dashboard/projects/$projectId/tasks/$taskId', + params: { + projectId: descriptor.projectId, + taskId: descriptor.taskId, + }, + hash: descriptor.hash, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + descriptor, + ) + } + + if (descriptor.kind === 'relative-task') { + return withSearch( + { + from: '/dashboard/projects/$projectId', + to: './tasks/$taskId', + params: (prev: { projectId?: string }) => ({ + ...prev, + projectId: descriptor.projectId, + taskId: descriptor.taskId, + }), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + descriptor, + ) + } + + if (descriptor.kind === 'project-updater') { + return withSearch( + { + to: '/dashboard/projects/$projectId', + params: (prev: { projectId?: string }) => ({ + ...prev, + projectId: descriptor.siblingProjectId, + }), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + descriptor, + ) + } + + if (descriptor.kind === 'report') { + return withSearch( + { + to: '/dashboard/reports/$reportId', + params: { reportId: descriptor.reportId }, + hash: descriptor.hash, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + descriptor, + ) + } + + return withSearch( + { + to: '/settings/{-$tab}', + params: { tab: descriptor.tab }, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + descriptor, + ) +} + +export function createMatchOptions(descriptor: MatchDescriptor) { + const base = { + includeSearch: descriptor.includeSearch, + fuzzy: descriptor.fuzzy, + } + + if (descriptor.kind === 'project') { + return { + ...base, + to: '/dashboard/projects/$projectId', + params: { projectId: descriptor.projectId }, + } + } + + if (descriptor.kind === 'task') { + return { + ...base, + to: '/dashboard/projects/$projectId/tasks/$taskId', + params: { + projectId: descriptor.projectId, + taskId: descriptor.taskId, + }, + } + } + + if (descriptor.kind === 'report') { + return { + ...base, + to: '/dashboard/reports/$reportId', + params: { reportId: descriptor.reportId }, + } + } + + return { + ...base, + to: '/settings/{-$tab}', + params: { tab: descriptor.tab }, + } +} + +export function createLinkLabel(descriptor: LinkDescriptor) { + if (descriptor.kind === 'project') { + return `Project ${descriptor.projectId}` + } + + if (descriptor.kind === 'task') { + return `Task ${descriptor.projectId}/${descriptor.taskId}` + } + + if (descriptor.kind === 'relative-task') { + return `Relative task ${descriptor.taskId}` + } + + if (descriptor.kind === 'project-updater') { + return `Updater project ${descriptor.siblingProjectId}` + } + + if (descriptor.kind === 'report') { + return `Report ${descriptor.reportId}` + } + + return `Settings ${descriptor.tab ?? 'default'}` +} + +export function readBuiltPublicHref(location: BuiltLocationSnapshot) { + return location.maskedLocation?.publicHref ?? location.publicHref +} + +export function patchMissingScrollToGlobal() { + const scrollGlobal = globalThis as ScrollGlobal + const hadScrollTo = Object.prototype.hasOwnProperty.call( + scrollGlobal, + 'scrollTo', + ) + const previousScrollTo = scrollGlobal.scrollTo + + if (typeof previousScrollTo === 'function') { + return () => {} + } + + const fallbackScrollTo = + typeof window !== 'undefined' && typeof window.scrollTo === 'function' + ? window.scrollTo.bind(window) + : () => {} + + Object.defineProperty(scrollGlobal, 'scrollTo', { + value: fallbackScrollTo, + configurable: true, + writable: true, + }) + + let restored = false + + return () => { + if (restored) { + return + } + + restored = true + + if (hadScrollTo) { + Object.defineProperty(scrollGlobal, 'scrollTo', { + value: previousScrollTo, + configurable: true, + writable: true, + }) + return + } + + delete (scrollGlobal as { scrollTo?: typeof window.scrollTo }).scrollTo + } +} + +function createActionSearch(cycle: number, label: string): RootSearch { + return { + page: cycle + 1, + filter: `${label}-${cycle}`, + view: cycle % 2 === 0 ? 'grid' : 'list', + preserve: `action-${label}-${cycle}`, + } +} + +function createActionSearchUpdater(cycle: number, label: string) { + return (prev: RootSearch): RootSearch => { + const normalized = normalizeRootSearch( + prev as unknown as Record, + ) + + return { + page: normalized.page + cycle + 1, + filter: `${label}-${cycle}`, + view: normalized.view === 'grid' ? 'detail' : 'grid', + preserve: `action-${label}-${cycle}`, + } + } +} + +function createNavigationCycle(cycle: number): Array { + const projectId = projectIds[(cycle * 7 + 1) % projectIds.length]! + const siblingProjectId = projectIds[(cycle * 7 + 2) % projectIds.length]! + const taskId = taskIds[(cycle * 5 + 3) % taskIds.length]! + const reportId = reportIds[(cycle * 3 + 4) % reportIds.length]! + const tab = + cycle % 2 === 0 ? settingsTabs[cycle % settingsTabs.length] : undefined + + return [ + { + label: `project-${cycle}`, + options: { + to: '/dashboard/projects/$projectId', + params: { projectId }, + search: createActionSearch(cycle, 'project'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `task-${cycle}`, + options: { + from: '/dashboard/projects/$projectId', + to: './tasks/$taskId', + params: (prev: { projectId?: string }) => ({ + ...prev, + projectId, + taskId, + }), + search: createActionSearchUpdater(cycle, 'task'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `sibling-project-${cycle}`, + options: { + to: '/dashboard/projects/$projectId', + params: (prev: { projectId?: string }) => ({ + ...prev, + projectId: siblingProjectId, + }), + search: true, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `report-${cycle}`, + options: { + to: '/dashboard/reports/$reportId', + params: { reportId }, + search: createActionSearch(cycle, 'report'), + hash: `report-${cycle}`, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `settings-${cycle}`, + options: { + to: '/settings/{-$tab}', + params: { tab }, + search: createActionSearch(cycle, 'settings'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + label: `hash-only-${cycle}`, + options: { + from: '/settings/{-$tab}', + to: '.', + params: { tab }, + search: true, + hash: `settings-hash-${cycle}`, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + ] +} + +const navigationSteps = Array.from( + { length: navigationCyclesPerRun }, + (_, cycle) => createNavigationCycle(cycle), +).flat() + +function getRouteMarker(container: ParentNode) { + return container.querySelector('[data-route-marker]') +} + +function assertCount( + container: ParentNode, + selector: string, + expected: number, +) { + const actual = container.querySelectorAll(selector).length + + if (actual !== expected) { + throw new Error(`Expected ${expected} ${selector} nodes, got ${actual}`) + } +} + +function assertActiveLink(container: ParentNode) { + if (!container.querySelector('.active-link')) { + throw new Error('Expected at least one active link class') + } +} + +function assertRouteMarker( + container: ParentNode, + expected: { + route: string + projectId?: string + taskId?: string + reportId?: string + tab?: string + }, +) { + const marker = getRouteMarker(container) + + if (!marker) { + throw new Error('Expected an active route marker') + } + + if (marker.dataset.routeMarker !== expected.route) { + throw new Error( + `Expected route marker ${expected.route}, got ${marker.dataset.routeMarker}`, + ) + } + + const checks = [ + ['projectId', expected.projectId], + ['taskId', expected.taskId], + ['reportId', expected.reportId], + ['tab', expected.tab], + ] as const + + for (const [field, value] of checks) { + if (value !== undefined && marker.dataset[field] !== value) { + throw new Error( + `Expected route marker ${field} ${value}, got ${marker.dataset[field]}`, + ) + } + } +} + +function assertHrefParity(container: ParentNode) { + for (const key of hrefComparisonKeys) { + const link = container.querySelector( + `[data-href-key="${key}"]`, + ) + const built = container.querySelector( + `[data-built-key="${key}"]`, + ) + const linkHref = link?.getAttribute('href') + const builtHref = built?.dataset.builtHref + + if (!linkHref || !builtHref || linkHref !== builtHref) { + throw new Error( + `Href parity failed for ${key}: link=${linkHref} built=${builtHref}`, + ) + } + } +} + +export function createLocationBuildingLinksWorkload( + framework: Framework, + mountTestApp: MountTestApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ + mountTestApp, + timeoutMs: 4_000, + }) + let stepIndex = 0 + + async function waitForScenarioReady() { + await lifecycle.waitForCounter( + () => + lifecycle.getContainer().querySelectorAll('[data-location-link="true"]') + .length, + linkPanelExpectedCount, + { label: 'location link panel render' }, + ) + await lifecycle.waitForCounter( + () => + lifecycle.getContainer().querySelectorAll('[data-match-probe="true"]') + .length, + matchProbeExpectedCount, + { label: 'location match probe render' }, + ) + await lifecycle.waitForCounter( + () => + lifecycle.getContainer().querySelectorAll('[data-built-href]').length, + buildLocationExpectedCount, + { label: 'location build href render' }, + ) + } + + async function before() { + stepIndex = 0 + await lifecycle.before() + await waitForScenarioReady() + } + + async function runSteps(count: number) { + for (let index = 0; index < count; index++) { + const step = navigationSteps[stepIndex % navigationSteps.length]! + stepIndex += 1 + + await lifecycle.navigate(step.options as NavigateOptions, { + wait: 'rendered', + label: step.label, + }) + } + } + + async function sanity() { + await before() + + try { + const container = lifecycle.getContainer() + assertCount( + container, + '[data-location-link="true"]', + linkPanelExpectedCount, + ) + assertCount( + container, + '[data-match-probe="true"]', + matchProbeExpectedCount, + ) + assertCount(container, '[data-built-href]', buildLocationExpectedCount) + assertActiveLink(container) + assertRouteMarker(container, { + route: 'project', + projectId: initialProjectId, + }) + assertHrefParity(container) + + await runSteps(navigationStepsPerCycle) + + await lifecycle.waitForCounter( + () => { + const marker = getRouteMarker(container) + return marker?.dataset.routeMarker === 'settings' ? 1 : 0 + }, + 1, + { label: 'settings route marker' }, + ) + + assertRouteMarker(container, { + route: 'settings', + tab: settingsTabs[0], + }) + assertActiveLink(container) + assertHrefParity(container) + } finally { + await lifecycle.after() + } + } + + return { + name: `client location building links loop (${framework})`, + before, + run: () => runSteps(navigationsPerBenchRun), + sanity, + after: lifecycle.after, + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/project.json b/benchmarks/client-nav/scenarios/location-building-links/solid/project.json new file mode 100644 index 0000000000..0c0e294bd1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-location-building-links-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/setup.ts b/benchmarks/client-nav/scenarios/location-building-links/solid/setup.ts new file mode 100644 index 0000000000..417e8d867d --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createLocationBuildingLinksWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createLocationBuildingLinksWorkload( + 'solid', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/location-building-links/solid/speed.bench.ts new file mode 100644 index 0000000000..ba98663dc2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav location-building-links', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/location-building-links/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/app.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/app.tsx new file mode 100644 index 0000000000..dae590ef02 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/app.tsx @@ -0,0 +1,27 @@ +import { RouterProvider } from '@tanstack/solid-router' +import { render } from 'solid-js/web' +import { patchMissingScrollToGlobal } from '../../shared.ts' +import { getRouter } from './router' + +export function mountTestApp(container: HTMLDivElement) { + const restoreScrollTo = patchMissingScrollToGlobal() + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + try { + dispose() + } finally { + restoreScrollTo() + } + }, + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/link-panel.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/link-panel.tsx new file mode 100644 index 0000000000..817c451112 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/link-panel.tsx @@ -0,0 +1,114 @@ +import { + Link, + useMatchRoute, + useRouter, + useRouterState, +} from '@tanstack/solid-router' +import { For, createMemo } from 'solid-js' +import { + buildLocationDescriptors, + createActiveOptions, + createLinkLabel, + createLinkOptions, + createLocationState, + createMatchOptions, + linkDescriptors, + matchDescriptors, + readBuiltPublicHref, + type BuiltLocationSnapshot, + type LinkDescriptor, + type MatchDescriptor, +} from '../../shared.ts' + +function createActiveProps(descriptor: LinkDescriptor) { + if (descriptor.styleVariant % 2 === 0) { + return { class: 'active-link' } + } + + return () => ({ + class: 'active-link active-link-fn', + 'data-active-key': descriptor.key, + }) +} + +function createInactiveProps(descriptor: LinkDescriptor) { + if (descriptor.styleVariant % 2 === 0) { + return { class: 'inactive-link' } + } + + return () => ({ + class: 'inactive-link inactive-link-fn', + 'data-inactive-key': descriptor.key, + }) +} + +function PanelLink(props: { descriptor: LinkDescriptor }) { + return ( + + {createLinkLabel(props.descriptor)} + + ) +} + +function MatchProbe(props: { descriptor: MatchDescriptor }) { + const matchRoute = useMatchRoute() + const params = matchRoute(createMatchOptions(props.descriptor) as any) + + return ( + + {params() ? 'matched' : 'unmatched'} + + ) +} + +function BuildLocationProbe(props: { descriptor: LinkDescriptor }) { + const router = useRouter() + const locationHref = useRouterState({ + select: (state) => state.location.href, + }) + const builtHref = createMemo(() => { + void locationHref() + + return readBuiltPublicHref( + router.buildLocation({ + _fromLocation: router.state.location, + ...createLinkOptions(props.descriptor), + state: createLocationState(props.descriptor), + } as any) as BuiltLocationSnapshot, + ) + }) + + return ( + + {builtHref()} + + ) +} + +export function LinkPanel() { + return ( +
+ + {(descriptor) => } + + + {(descriptor) => } + + + {(descriptor) => } + +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routeTree.gen.ts new file mode 100644 index 0000000000..f0e325a8f9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routeTree.gen.ts @@ -0,0 +1,174 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as DashboardProjectsProjectIdTasksTaskIdRouteImport } from './routes/dashboard.projects.$projectId.tasks.$taskId' +import { Route as DashboardProjectsProjectIdRouteImport } from './routes/dashboard.projects.$projectId' +import { Route as DashboardReportsReportIdRouteImport } from './routes/dashboard.reports.$reportId' +import { Route as DashboardRouteImport } from './routes/dashboard' +import { Route as SettingsTabRouteImport } from './routes/settings.{-$tab}' + +const DashboardRoute = DashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsTabRoute = SettingsTabRouteImport.update({ + id: '/settings/{-$tab}', + path: '/settings/{-$tab}', + getParentRoute: () => rootRouteImport, +} as any) +const DashboardProjectsProjectIdRoute = + DashboardProjectsProjectIdRouteImport.update({ + id: '/projects/$projectId', + path: '/projects/$projectId', + getParentRoute: () => DashboardRoute, + } as any) +const DashboardReportsReportIdRoute = DashboardReportsReportIdRouteImport.update( + { + id: '/reports/$reportId', + path: '/reports/$reportId', + getParentRoute: () => DashboardRoute, + } as any, +) +const DashboardProjectsProjectIdTasksTaskIdRoute = + DashboardProjectsProjectIdTasksTaskIdRouteImport.update({ + id: '/tasks/$taskId', + path: '/tasks/$taskId', + getParentRoute: () => DashboardProjectsProjectIdRoute, + } as any) + +export interface FileRoutesByFullPath { + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesByTo { + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + fileRoutesByTo: FileRoutesByTo + to: + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + id: + | '__root__' + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + DashboardRoute: typeof DashboardRouteWithChildren + SettingsTabRoute: typeof SettingsTabRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardRouteImport + parentRoute: typeof rootRouteImport + } + '/settings/{-$tab}': { + id: '/settings/{-$tab}' + path: '/settings/{-$tab}' + fullPath: '/settings/{-$tab}' + preLoaderRoute: typeof SettingsTabRouteImport + parentRoute: typeof rootRouteImport + } + '/dashboard/projects/$projectId': { + id: '/dashboard/projects/$projectId' + path: '/projects/$projectId' + fullPath: '/dashboard/projects/$projectId' + preLoaderRoute: typeof DashboardProjectsProjectIdRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/reports/$reportId': { + id: '/dashboard/reports/$reportId' + path: '/reports/$reportId' + fullPath: '/dashboard/reports/$reportId' + preLoaderRoute: typeof DashboardReportsReportIdRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/projects/$projectId/tasks/$taskId': { + id: '/dashboard/projects/$projectId/tasks/$taskId' + path: '/tasks/$taskId' + fullPath: '/dashboard/projects/$projectId/tasks/$taskId' + preLoaderRoute: typeof DashboardProjectsProjectIdTasksTaskIdRouteImport + parentRoute: typeof DashboardProjectsProjectIdRoute + } + } +} + +interface DashboardProjectsProjectIdRouteChildren { + DashboardProjectsProjectIdTasksTaskIdRoute: typeof DashboardProjectsProjectIdTasksTaskIdRoute +} + +const DashboardProjectsProjectIdRouteChildren: DashboardProjectsProjectIdRouteChildren = + { + DashboardProjectsProjectIdTasksTaskIdRoute: + DashboardProjectsProjectIdTasksTaskIdRoute, + } + +const DashboardProjectsProjectIdRouteWithChildren = + DashboardProjectsProjectIdRoute._addFileChildren( + DashboardProjectsProjectIdRouteChildren, + ) + +interface DashboardRouteChildren { + DashboardProjectsProjectIdRoute: typeof DashboardProjectsProjectIdRouteWithChildren + DashboardReportsReportIdRoute: typeof DashboardReportsReportIdRoute +} + +const DashboardRouteChildren: DashboardRouteChildren = { + DashboardProjectsProjectIdRoute: DashboardProjectsProjectIdRouteWithChildren, + DashboardReportsReportIdRoute: DashboardReportsReportIdRoute, +} + +const DashboardRouteWithChildren = DashboardRoute._addFileChildren( + DashboardRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + DashboardRoute: DashboardRouteWithChildren, + SettingsTabRoute: SettingsTabRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/router.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/router.tsx new file mode 100644 index 0000000000..1e31da215e --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { initialLocation } from '../../shared.ts' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..a1269fcf70 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/__root.tsx @@ -0,0 +1,19 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' +import { normalizeRootSearch } from '../../../shared.ts' +import { LinkPanel } from '../link-panel' + +export const Route = createRootRoute({ + validateSearch: normalizeRootSearch, + component: RootComponent, +}) + +function RootComponent() { + return ( + <> + +
+ +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx new file mode 100644 index 0000000000..6accb49f43 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute( + '/dashboard/projects/$projectId/tasks/$taskId', +)({ + component: TaskPage, +}) + +function TaskPage() { + const params = Route.useParams() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.projects.$projectId.tsx new file mode 100644 index 0000000000..4d627a175f --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.projects.$projectId.tsx @@ -0,0 +1,16 @@ +import { Outlet, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/dashboard/projects/$projectId')({ + component: ProjectPage, +}) + +function ProjectPage() { + const params = Route.useParams() + + return ( + <> +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.reports.$reportId.tsx new file mode 100644 index 0000000000..9eec5a445f --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.reports.$reportId.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/dashboard/reports/$reportId')({ + component: ReportPage, +}) + +function ReportPage() { + const params = Route.useParams() + + return
+} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.tsx new file mode 100644 index 0000000000..e2a5ce6719 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/dashboard.tsx @@ -0,0 +1,9 @@ +import { Outlet, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/dashboard')({ + component: DashboardPage, +}) + +function DashboardPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/settings.{-$tab}.tsx b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/settings.{-$tab}.tsx new file mode 100644 index 0000000000..4308be7326 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/src/routes/settings.{-$tab}.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/settings/{-$tab}')({ + component: SettingsPage, +}) + +function SettingsPage() { + const params = Route.useParams() + + return
+} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/tsconfig.json b/benchmarks/client-nav/scenarios/location-building-links/solid/tsconfig.json new file mode 100644 index 0000000000..b549cd9fe8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/solid/vite.config.ts b/benchmarks/client-nav/scenarios/location-building-links/solid/vite.config.ts new file mode 100644 index 0000000000..a7129269e7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/solid/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav location-building-links (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/project.json b/benchmarks/client-nav/scenarios/location-building-links/vue/project.json new file mode 100644 index 0000000000..145a191637 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-location-building-links-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/setup.ts b/benchmarks/client-nav/scenarios/location-building-links/vue/setup.ts new file mode 100644 index 0000000000..ce65e71203 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createLocationBuildingLinksWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createLocationBuildingLinksWorkload( + 'vue', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/location-building-links/vue/speed.bench.ts new file mode 100644 index 0000000000..ba98663dc2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav location-building-links', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/location-building-links/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/app.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/app.tsx new file mode 100644 index 0000000000..f75bbde825 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/app.tsx @@ -0,0 +1,34 @@ +import { RouterProvider } from '@tanstack/vue-router' +import { createApp } from 'vue' +import { patchMissingScrollToGlobal } from '../../shared.ts' +import { getRouter } from './router' +import type {} from '@tanstack/router-core' + +export function mountTestApp(container: HTMLDivElement) { + const restoreScrollTo = patchMissingScrollToGlobal() + const router = getRouter() + const app = createApp({ + setup() { + return () => + }, + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + try { + app.unmount() + } finally { + restoreScrollTo() + } + }, + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/link-panel.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/link-panel.tsx new file mode 100644 index 0000000000..9905307f2a --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/link-panel.tsx @@ -0,0 +1,141 @@ +import * as Vue from 'vue' +import { + Link, + useMatchRoute, + useRouter, + useRouterState, +} from '@tanstack/vue-router' +import { + buildLocationDescriptors, + createActiveOptions, + createLinkLabel, + createLinkOptions, + createLocationState, + createMatchOptions, + linkDescriptors, + matchDescriptors, + readBuiltPublicHref, + type BuiltLocationSnapshot, + type LinkDescriptor, + type MatchDescriptor, +} from '../../shared.ts' + +function createActiveProps(descriptor: LinkDescriptor) { + if (descriptor.styleVariant % 2 === 0) { + return { class: 'active-link' } + } + + return () => ({ + class: 'active-link active-link-fn', + 'data-active-key': descriptor.key, + }) +} + +function createInactiveProps(descriptor: LinkDescriptor) { + if (descriptor.styleVariant % 2 === 0) { + return { class: 'inactive-link' } + } + + return () => ({ + class: 'inactive-link inactive-link-fn', + 'data-inactive-key': descriptor.key, + }) +} + +const PanelLink = Vue.defineComponent({ + props: { + descriptor: { + type: Object as Vue.PropType, + required: true, + }, + }, + setup(props) { + return () => ( + + {createLinkLabel(props.descriptor)} + + ) + }, +}) + +const MatchProbe = Vue.defineComponent({ + props: { + descriptor: { + type: Object as Vue.PropType, + required: true, + }, + }, + setup(props) { + const matchRoute = useMatchRoute() + const params = matchRoute(createMatchOptions(props.descriptor) as any) + + return () => ( + + {params.value ? 'matched' : 'unmatched'} + + ) + }, +}) + +const BuildLocationProbe = Vue.defineComponent({ + props: { + descriptor: { + type: Object as Vue.PropType, + required: true, + }, + }, + setup(props) { + const router = useRouter() + const locationHref = useRouterState({ + select: (state) => state.location.href, + }) + + return () => { + void locationHref.value + + const builtHref = readBuiltPublicHref( + router.buildLocation({ + _fromLocation: router.state.location, + ...createLinkOptions(props.descriptor), + state: createLocationState(props.descriptor), + } as any) as BuiltLocationSnapshot, + ) + + return ( + + {builtHref} + + ) + } + }, +}) + +export const LinkPanel = Vue.defineComponent({ + setup() { + return () => ( +
+ {linkDescriptors.map((descriptor) => ( + + ))} + {matchDescriptors.map((descriptor) => ( + + ))} + {buildLocationDescriptors.map((descriptor) => ( + + ))} +
+ ) + }, +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routeTree.gen.ts new file mode 100644 index 0000000000..1ca619af90 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routeTree.gen.ts @@ -0,0 +1,174 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as DashboardProjectsProjectIdTasksTaskIdRouteImport } from './routes/dashboard.projects.$projectId.tasks.$taskId' +import { Route as DashboardProjectsProjectIdRouteImport } from './routes/dashboard.projects.$projectId' +import { Route as DashboardReportsReportIdRouteImport } from './routes/dashboard.reports.$reportId' +import { Route as DashboardRouteImport } from './routes/dashboard' +import { Route as SettingsTabRouteImport } from './routes/settings.{-$tab}' + +const DashboardRoute = DashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsTabRoute = SettingsTabRouteImport.update({ + id: '/settings/{-$tab}', + path: '/settings/{-$tab}', + getParentRoute: () => rootRouteImport, +} as any) +const DashboardProjectsProjectIdRoute = + DashboardProjectsProjectIdRouteImport.update({ + id: '/projects/$projectId', + path: '/projects/$projectId', + getParentRoute: () => DashboardRoute, + } as any) +const DashboardReportsReportIdRoute = DashboardReportsReportIdRouteImport.update( + { + id: '/reports/$reportId', + path: '/reports/$reportId', + getParentRoute: () => DashboardRoute, + } as any, +) +const DashboardProjectsProjectIdTasksTaskIdRoute = + DashboardProjectsProjectIdTasksTaskIdRouteImport.update({ + id: '/tasks/$taskId', + path: '/tasks/$taskId', + getParentRoute: () => DashboardProjectsProjectIdRoute, + } as any) + +export interface FileRoutesByFullPath { + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesByTo { + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/dashboard': typeof DashboardRouteWithChildren + '/settings/{-$tab}': typeof SettingsTabRoute + '/dashboard/projects/$projectId': typeof DashboardProjectsProjectIdRouteWithChildren + '/dashboard/reports/$reportId': typeof DashboardReportsReportIdRoute + '/dashboard/projects/$projectId/tasks/$taskId': typeof DashboardProjectsProjectIdTasksTaskIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + fileRoutesByTo: FileRoutesByTo + to: + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + id: + | '__root__' + | '/dashboard' + | '/settings/{-$tab}' + | '/dashboard/projects/$projectId' + | '/dashboard/reports/$reportId' + | '/dashboard/projects/$projectId/tasks/$taskId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + DashboardRoute: typeof DashboardRouteWithChildren + SettingsTabRoute: typeof SettingsTabRoute +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardRouteImport + parentRoute: typeof rootRouteImport + } + '/settings/{-$tab}': { + id: '/settings/{-$tab}' + path: '/settings/{-$tab}' + fullPath: '/settings/{-$tab}' + preLoaderRoute: typeof SettingsTabRouteImport + parentRoute: typeof rootRouteImport + } + '/dashboard/projects/$projectId': { + id: '/dashboard/projects/$projectId' + path: '/projects/$projectId' + fullPath: '/dashboard/projects/$projectId' + preLoaderRoute: typeof DashboardProjectsProjectIdRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/reports/$reportId': { + id: '/dashboard/reports/$reportId' + path: '/reports/$reportId' + fullPath: '/dashboard/reports/$reportId' + preLoaderRoute: typeof DashboardReportsReportIdRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/projects/$projectId/tasks/$taskId': { + id: '/dashboard/projects/$projectId/tasks/$taskId' + path: '/tasks/$taskId' + fullPath: '/dashboard/projects/$projectId/tasks/$taskId' + preLoaderRoute: typeof DashboardProjectsProjectIdTasksTaskIdRouteImport + parentRoute: typeof DashboardProjectsProjectIdRoute + } + } +} + +interface DashboardProjectsProjectIdRouteChildren { + DashboardProjectsProjectIdTasksTaskIdRoute: typeof DashboardProjectsProjectIdTasksTaskIdRoute +} + +const DashboardProjectsProjectIdRouteChildren: DashboardProjectsProjectIdRouteChildren = + { + DashboardProjectsProjectIdTasksTaskIdRoute: + DashboardProjectsProjectIdTasksTaskIdRoute, + } + +const DashboardProjectsProjectIdRouteWithChildren = + DashboardProjectsProjectIdRoute._addFileChildren( + DashboardProjectsProjectIdRouteChildren, + ) + +interface DashboardRouteChildren { + DashboardProjectsProjectIdRoute: typeof DashboardProjectsProjectIdRouteWithChildren + DashboardReportsReportIdRoute: typeof DashboardReportsReportIdRoute +} + +const DashboardRouteChildren: DashboardRouteChildren = { + DashboardProjectsProjectIdRoute: DashboardProjectsProjectIdRouteWithChildren, + DashboardReportsReportIdRoute: DashboardReportsReportIdRoute, +} + +const DashboardRouteWithChildren = DashboardRoute._addFileChildren( + DashboardRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + DashboardRoute: DashboardRouteWithChildren, + SettingsTabRoute: SettingsTabRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/router.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/router.tsx new file mode 100644 index 0000000000..9768a2f453 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { initialLocation } from '../../shared.ts' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..209ccc5f5a --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/__root.tsx @@ -0,0 +1,19 @@ +import { Outlet, createRootRoute } from '@tanstack/vue-router' +import { normalizeRootSearch } from '../../../shared.ts' +import { LinkPanel } from '../link-panel' + +export const Route = createRootRoute({ + validateSearch: normalizeRootSearch, + component: RootComponent, +}) + +function RootComponent() { + return ( + <> + +
+ +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx new file mode 100644 index 0000000000..9fd06979ec --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.projects.$projectId.tasks.$taskId.tsx @@ -0,0 +1,22 @@ +import * as Vue from 'vue' +import { createFileRoute } from '@tanstack/vue-router' + +const TaskPage = Vue.defineComponent({ + setup() { + const params = Route.useParams() + + return () => ( +
+ ) + }, +}) + +export const Route = createFileRoute( + '/dashboard/projects/$projectId/tasks/$taskId', +)({ + component: TaskPage, +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.projects.$projectId.tsx new file mode 100644 index 0000000000..5e21233b6d --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.projects.$projectId.tsx @@ -0,0 +1,22 @@ +import * as Vue from 'vue' +import { Outlet, createFileRoute } from '@tanstack/vue-router' + +const ProjectPage = Vue.defineComponent({ + setup() { + const params = Route.useParams() + + return () => ( + <> +
+ + + ) + }, +}) + +export const Route = createFileRoute('/dashboard/projects/$projectId')({ + component: ProjectPage, +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.reports.$reportId.tsx new file mode 100644 index 0000000000..7c6ae8e223 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.reports.$reportId.tsx @@ -0,0 +1,16 @@ +import * as Vue from 'vue' +import { createFileRoute } from '@tanstack/vue-router' + +const ReportPage = Vue.defineComponent({ + setup() { + const params = Route.useParams() + + return () => ( +
+ ) + }, +}) + +export const Route = createFileRoute('/dashboard/reports/$reportId')({ + component: ReportPage, +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.tsx new file mode 100644 index 0000000000..40b132e23b --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/dashboard.tsx @@ -0,0 +1,9 @@ +import { Outlet, createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/dashboard')({ + component: DashboardPage, +}) + +function DashboardPage() { + return +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/settings.{-$tab}.tsx b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/settings.{-$tab}.tsx new file mode 100644 index 0000000000..032be8ee95 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/src/routes/settings.{-$tab}.tsx @@ -0,0 +1,16 @@ +import * as Vue from 'vue' +import { createFileRoute } from '@tanstack/vue-router' + +const SettingsPage = Vue.defineComponent({ + setup() { + const params = Route.useParams() + + return () => ( +
+ ) + }, +}) + +export const Route = createFileRoute('/settings/{-$tab}')({ + component: SettingsPage, +}) diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/tsconfig.json b/benchmarks/client-nav/scenarios/location-building-links/vue/tsconfig.json new file mode 100644 index 0000000000..24bdb3e3cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/location-building-links/vue/vite.config.ts b/benchmarks/client-nav/scenarios/location-building-links/vue/vite.config.ts new file mode 100644 index 0000000000..efbc004765 --- /dev/null +++ b/benchmarks/client-nav/scenarios/location-building-links/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav location-building-links (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/project.json b/benchmarks/client-nav/scenarios/masking-rewrites/react/project.json new file mode 100644 index 0000000000..53c5033a29 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-masking-rewrites-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/setup.ts b/benchmarks/client-nav/scenarios/masking-rewrites/react/setup.ts new file mode 100644 index 0000000000..015effbf69 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createMaskingRewritesWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createMaskingRewritesWorkload( + 'react', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/speed.bench.ts b/benchmarks/client-nav/scenarios/masking-rewrites/react/speed.bench.ts new file mode 100644 index 0000000000..87e687c988 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav masking-rewrites', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/speed.flame.ts b/benchmarks/client-nav/scenarios/masking-rewrites/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/app.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/app.tsx new file mode 100644 index 0000000000..439c4ed204 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/app.tsx @@ -0,0 +1,29 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { patchMissingScrollToGlobal } from '../../shared.ts' +import { getRouter } from './router' + +export function mountTestApp(container: HTMLDivElement) { + const restoreScrollTo = patchMissingScrollToGlobal() + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + try { + reactRoot.unmount() + } finally { + restoreScrollTo() + } + }, + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/link-panel.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/link-panel.tsx new file mode 100644 index 0000000000..7b0aa3fff6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/link-panel.tsx @@ -0,0 +1,66 @@ +import { Link, useRouter, useRouterState } from '@tanstack/react-router' +import { + buildLocationDescriptors, + createBuildLocationOptions, + createLinkLabel, + createLinkOptions, + createMaskingLinkActiveOptions, + linkDescriptors, + readVisiblePublicHref, + type BuiltLocationSnapshot, + type LinkDescriptor, +} from '../../shared.ts' + +function PanelLink({ descriptor }: { descriptor: LinkDescriptor }) { + return ( + + {createLinkLabel(descriptor)} + + ) +} + +function BuildLocationProbe({ descriptor }: { descriptor: LinkDescriptor }) { + const router = useRouter() + const locationHref = useRouterState({ + select: (state) => state.location.href, + }) + void locationHref + + const builtLocation = router.buildLocation( + createBuildLocationOptions(router.state.location, descriptor) as any, + ) as unknown as BuiltLocationSnapshot + const visiblePublicHref = readVisiblePublicHref(builtLocation) + + return ( + + {visiblePublicHref} + + ) +} + +export function LinkPanel() { + return ( +
+ {linkDescriptors.map((descriptor) => ( + + ))} + {buildLocationDescriptors.map((descriptor) => ( + + ))} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routeTree.tsx new file mode 100644 index 0000000000..bd24ba8ca9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routeTree.tsx @@ -0,0 +1,21 @@ +import { createRouteMask } from '@tanstack/react-router' +import { photoModalMaskOptions } from '../../shared.ts' +import { rootRoute } from './routes/__root' +import { photoDetailRoute } from './routes/photos.$photoId' +import { photoModalRoute } from './routes/photos.$photoId.modal' +import { photosRoute } from './routes/photos' +import { settingsProfileRoute } from './routes/settings.profile' +import { teamProjectRoute } from './routes/teams.$teamId.projects.$projectId' + +export const routeTree = rootRoute.addChildren([ + photosRoute, + photoDetailRoute, + photoModalRoute, + settingsProfileRoute, + teamProjectRoute, +]) + +export const photoModalMask = createRouteMask({ + routeTree, + ...photoModalMaskOptions, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/router.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/router.tsx new file mode 100644 index 0000000000..17f0d8aafa --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/router.tsx @@ -0,0 +1,26 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { + createMaskingRewrite, + initialPublicHref, + routerBasepath, +} from '../../shared.ts' +import { photoModalMask, routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialPublicHref], + }), + basepath: routerBasepath, + rewrite: createMaskingRewrite(), + trailingSlash: 'never', + routeTree, + routeMasks: [photoModalMask], + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/__root.tsx new file mode 100644 index 0000000000..c6ebb10cca --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/__root.tsx @@ -0,0 +1,19 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' +import { normalizeMaskingSearch } from '../../../shared.ts' +import { LinkPanel } from '../link-panel' + +export const rootRoute = createRootRoute({ + validateSearch: normalizeMaskingSearch, + component: Root, +}) + +function Root() { + return ( + <> + +
+ +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.$photoId.modal.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.$photoId.modal.tsx new file mode 100644 index 0000000000..5217b7787d --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.$photoId.modal.tsx @@ -0,0 +1,20 @@ +import { createRoute } from '@tanstack/react-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const photoModalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photoModal, + component: PhotoModalPage, +}) + +function PhotoModalPage() { + const params = photoModalRoute.useParams() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.$photoId.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.$photoId.tsx new file mode 100644 index 0000000000..05fe49a4bd --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.$photoId.tsx @@ -0,0 +1,20 @@ +import { createRoute } from '@tanstack/react-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const photoDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photoDetail, + component: PhotoDetailPage, +}) + +function PhotoDetailPage() { + const params = photoDetailRoute.useParams() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.tsx new file mode 100644 index 0000000000..0410604643 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/photos.tsx @@ -0,0 +1,13 @@ +import { createRoute } from '@tanstack/react-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const photosRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photos, + component: PhotosPage, +}) + +function PhotosPage() { + return
+} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/settings.profile.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/settings.profile.tsx new file mode 100644 index 0000000000..27d63c4ac1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/settings.profile.tsx @@ -0,0 +1,13 @@ +import { createRoute } from '@tanstack/react-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const settingsProfileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.settingsProfile, + component: SettingsProfilePage, +}) + +function SettingsProfilePage() { + return
+} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/teams.$teamId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/teams.$teamId.projects.$projectId.tsx new file mode 100644 index 0000000000..795cfc2797 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/src/routes/teams.$teamId.projects.$projectId.tsx @@ -0,0 +1,21 @@ +import { createRoute } from '@tanstack/react-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const teamProjectRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.teamProject, + component: TeamProjectPage, +}) + +function TeamProjectPage() { + const params = teamProjectRoute.useParams() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/tsconfig.json b/benchmarks/client-nav/scenarios/masking-rewrites/react/tsconfig.json new file mode 100644 index 0000000000..e5056ec745 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/react/vite.config.ts b/benchmarks/client-nav/scenarios/masking-rewrites/react/vite.config.ts new file mode 100644 index 0000000000..773b5f6957 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav masking-rewrites (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/shared.ts b/benchmarks/client-nav/scenarios/masking-rewrites/shared.ts new file mode 100644 index 0000000000..c49f5ad263 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/shared.ts @@ -0,0 +1,1118 @@ +import type { LocationRewrite, NavigateOptions } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' +import { + createClientNavLifecycle, + warnClientNavDevMode, + type Framework, + type MountTestApp, +} from '#client-nav/lifecycle' + +export type MaskingSearch = { + page: number + filter: string + layout: string +} + +export type LinkKind = + | 'photo-detail' + | 'auto-masked-modal' + | 'explicit-masked-modal' + | 'unmasked-modal' + | 'legacy-settings' + | 'team-project' + +export type LinkDescriptor = { + index: number + key: string + testId: string + kind: LinkKind + photoId: string + teamId: string + projectId: string + hash: string +} + +export type BuiltLocationSnapshot = { + href: string + publicHref: string + pathname: string + hash: string + maskedLocation?: { + href: string + publicHref: string + pathname: string + state: any + } + state: any +} + +type RouteMarkerExpectation = { + route: string + photoId?: string + teamId?: string + projectId?: string +} + +type NavigationStep = + | { + kind: 'navigate' + label: string + options: Record + } + | { + kind: 'click' + label: string + testId: string + } + +type ScrollGlobal = typeof globalThis & { + scrollTo?: typeof window.scrollTo +} + +const scenarioRandom = createDeterministicRandom(0x13a5cafe) + +const linkKinds = [ + 'photo-detail', + 'auto-masked-modal', + 'explicit-masked-modal', + 'unmasked-modal', + 'legacy-settings', + 'team-project', +] as const satisfies ReadonlyArray + +export const MASKING_ROUTE_PATHS = { + photos: '/photos', + photoDetail: '/photos/$photoId', + photoModal: '/photos/$photoId/modal', + settingsProfile: '/settings/profile', + teamProject: '/teams/$teamId/projects/$projectId', +} as const + +export const MASKING_ROUTE_MARKERS = { + photos: 'photos', + photoDetail: 'photo-detail', + photoModal: 'photo-modal', + settingsProfile: 'settings-profile', + teamProject: 'team-project', +} as const + +export const routerBasepath = '/app' +export const publicLocale = 'en' +export const publicLocalePrefix = `/${publicLocale}` +export const linkPanelExpectedCount = 72 +export const buildLocationExpectedCount = 24 +export const navigationStepsPerCycle = 8 +export const navigationCyclesPerRun = 2 +export const navigationsPerBenchRun = + navigationStepsPerCycle * navigationCyclesPerRun + +export const photoModalMaskOptions = { + from: MASKING_ROUTE_PATHS.photoModal, + to: MASKING_ROUTE_PATHS.photoDetail, + params: true, + search: { page: 1, filter: 'masked', layout: 'detail' }, + state: { scenario: 'masking-rewrites', mask: 'photo-modal' } as any, + unmaskOnReload: true, +} as const + +function createSegments(prefix: string, count: number) { + const values: Array = [] + + for (let index = 0; index < count; index++) { + values.push(`${prefix}-${index}-${randomSegment(scenarioRandom)}`) + } + + return values +} + +export const photoIds = createSegments('photo', 80) +export const teamIds = createSegments('team', 32) +export const projectIds = createSegments('project', 48) +export const initialPhotoId = photoIds[0]! + +function ensureLeadingSlash(pathname: string) { + return pathname.startsWith('/') ? pathname : `/${pathname}` +} + +function trimTrailingSlash(pathname: string) { + if (pathname !== '/' && pathname.endsWith('/')) { + return pathname.slice(0, -1) + } + + return pathname +} + +function withOptionalTrailingSlash(pathname: string, trailingSlash: boolean) { + const normalized = ensureLeadingSlash(pathname) + + if (trailingSlash) { + return normalized.endsWith('/') ? normalized : `${normalized}/` + } + + return trimTrailingSlash(normalized) +} + +function addSearchAndHash( + pathname: string, + search?: MaskingSearch, + hash?: string, +) { + const searchStr = search ? stringifySearch(search) : '' + const hashStr = hash ? `#${hash}` : '' + + return `${pathname}${searchStr}${hashStr}` +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const numericValue = Number(value) + + if (Number.isFinite(numericValue) && numericValue > 0) { + return Math.trunc(numericValue) + } + + return fallback +} + +function normalizeString(value: unknown, fallback: string) { + if (typeof value === 'string' && value.length > 0) { + return value + } + + return fallback +} + +export function normalizeMaskingSearch( + search: Record, +): MaskingSearch { + return { + page: normalizePositiveInteger(search.page, 1), + filter: normalizeString(search.filter, 'all'), + layout: normalizeString(search.layout, 'grid'), + } +} + +export function stringifySearch(search: MaskingSearch) { + const params = new URLSearchParams() + params.set('page', `${search.page}`) + params.set('filter', search.filter) + params.set('layout', search.layout) + + return `?${params.toString()}` +} + +export function createActionSearch( + cycle: number, + label: string, +): MaskingSearch { + return { + page: cycle + 2, + filter: `${label}-${cycle}`, + layout: cycle % 2 === 0 ? 'grid' : 'detail', + } +} + +export function internalPhotosPath(trailingSlash = false) { + return withOptionalTrailingSlash(MASKING_ROUTE_PATHS.photos, trailingSlash) +} + +export function internalPhotoDetailPath( + photoId: string, + trailingSlash = false, +) { + return withOptionalTrailingSlash( + MASKING_ROUTE_PATHS.photoDetail.replace('$photoId', photoId), + trailingSlash, + ) +} + +export function internalPhotoModalPath(photoId: string, trailingSlash = false) { + return withOptionalTrailingSlash( + MASKING_ROUTE_PATHS.photoModal.replace('$photoId', photoId), + trailingSlash, + ) +} + +export function internalSettingsProfilePath(trailingSlash = false) { + return withOptionalTrailingSlash( + MASKING_ROUTE_PATHS.settingsProfile, + trailingSlash, + ) +} + +export function internalTeamProjectPath( + teamId: string, + projectId: string, + trailingSlash = false, +) { + return withOptionalTrailingSlash( + MASKING_ROUTE_PATHS.teamProject + .replace('$teamId', teamId) + .replace('$projectId', projectId), + trailingSlash, + ) +} + +export function publicPhotosHref( + search?: MaskingSearch, + trailingSlash = false, +) { + return addSearchAndHash( + `${routerBasepath}${publicLocalePrefix}${internalPhotosPath(trailingSlash)}`, + search, + ) +} + +export function publicPhotoDetailHref( + photoId: string, + search?: MaskingSearch, + hash?: string, + trailingSlash = false, +) { + return addSearchAndHash( + `${routerBasepath}${publicLocalePrefix}${internalPhotoDetailPath( + photoId, + trailingSlash, + )}`, + search, + hash, + ) +} + +export function publicPhotoModalHref( + photoId: string, + search?: MaskingSearch, + hash?: string, + trailingSlash = false, +) { + return addSearchAndHash( + `${routerBasepath}${publicLocalePrefix}${internalPhotoModalPath( + photoId, + trailingSlash, + )}`, + search, + hash, + ) +} + +export function publicLegacySettingsHref( + search?: MaskingSearch, + trailingSlash = false, +) { + return addSearchAndHash( + `${routerBasepath}${publicLocalePrefix}${withOptionalTrailingSlash( + '/legacy/profile', + trailingSlash, + )}`, + search, + ) +} + +export function publicTeamProjectHref( + teamId: string, + projectId: string, + search?: MaskingSearch, + hash?: string, + trailingSlash = false, +) { + return addSearchAndHash( + `${routerBasepath}${publicLocalePrefix}${internalTeamProjectPath( + teamId, + projectId, + trailingSlash, + )}`, + search, + hash, + ) +} + +export const initialPublicHref = publicPhotosHref({ + page: 1, + filter: 'initial', + layout: 'grid', +}) + +function stripPublicLocalePrefix(pathname: string) { + if ( + pathname === publicLocalePrefix || + pathname === `${publicLocalePrefix}/` + ) { + return '/' + } + + if (pathname.startsWith(`${publicLocalePrefix}/`)) { + return pathname.slice(publicLocalePrefix.length) + } + + return pathname +} + +function addPublicLocalePrefix(pathname: string) { + if (pathname === '/') { + return `${publicLocalePrefix}/` + } + + if (pathname.startsWith(`${publicLocalePrefix}/`)) { + return pathname + } + + return `${publicLocalePrefix}${ensureLeadingSlash(pathname)}` +} + +function isLegacySettingsPath(pathname: string) { + return trimTrailingSlash(pathname) === `${publicLocalePrefix}/legacy/profile` +} + +function isInternalSettingsProfilePath(pathname: string) { + return trimTrailingSlash(pathname) === MASKING_ROUTE_PATHS.settingsProfile +} + +export function createMaskingRewrite(): LocationRewrite { + return { + input: ({ url }) => { + const nextUrl = new URL(url.href) + + if (isLegacySettingsPath(nextUrl.pathname)) { + nextUrl.pathname = withOptionalTrailingSlash( + MASKING_ROUTE_PATHS.settingsProfile, + nextUrl.pathname.endsWith('/'), + ) + return nextUrl + } + + nextUrl.pathname = stripPublicLocalePrefix(nextUrl.pathname) + + return nextUrl + }, + output: ({ url }) => { + const nextUrl = new URL(url.href) + + if (isInternalSettingsProfilePath(nextUrl.pathname)) { + nextUrl.pathname = withOptionalTrailingSlash( + `${publicLocalePrefix}/legacy/profile`, + nextUrl.pathname.endsWith('/'), + ) + return nextUrl + } + + nextUrl.pathname = addPublicLocalePrefix(nextUrl.pathname) + + return nextUrl + }, + } +} + +export function patchMissingScrollToGlobal() { + const scrollGlobal = globalThis as ScrollGlobal + const hadScrollTo = Object.prototype.hasOwnProperty.call( + scrollGlobal, + 'scrollTo', + ) + const previousScrollTo = scrollGlobal.scrollTo + + if (typeof previousScrollTo === 'function') { + return () => {} + } + + const fallbackScrollTo = + typeof window !== 'undefined' && typeof window.scrollTo === 'function' + ? window.scrollTo.bind(window) + : () => {} + + Object.defineProperty(scrollGlobal, 'scrollTo', { + value: fallbackScrollTo, + configurable: true, + writable: true, + }) + + let restored = false + + return () => { + if (restored) { + return + } + + restored = true + + if (hadScrollTo) { + Object.defineProperty(scrollGlobal, 'scrollTo', { + value: previousScrollTo, + configurable: true, + writable: true, + }) + return + } + + delete (scrollGlobal as { scrollTo?: typeof window.scrollTo }).scrollTo + } +} + +function createLinkDescriptor(index: number): LinkDescriptor { + const kind = linkKinds[index % linkKinds.length]! + + return { + index, + key: `masking-link-${index}`, + testId: `masking-link-${index}`, + kind, + photoId: index === 0 ? initialPhotoId : photoIds[index % photoIds.length]!, + teamId: teamIds[(index * 5 + 3) % teamIds.length]!, + projectId: projectIds[(index * 7 + 2) % projectIds.length]!, + hash: `panel-${index % 11}`, + } +} + +export const linkDescriptors = Array.from( + { length: linkPanelExpectedCount }, + (_, index) => createLinkDescriptor(index), +) + +export const buildLocationDescriptors = linkDescriptors.slice( + 0, + buildLocationExpectedCount, +) + +function getDescriptorByKind(kind: LinkKind, ordinal = 0) { + let seen = 0 + + for (const descriptor of linkDescriptors) { + if (descriptor.kind !== kind) { + continue + } + + if (seen === ordinal) { + return descriptor + } + + seen += 1 + } + + throw new Error(`Missing masking rewrite link descriptor for ${kind}`) +} + +const teamNavigationDescriptors = [ + getDescriptorByKind('team-project', 0), + getDescriptorByKind('team-project', 1), +] + +export const requiredNavigationLinkTestIds = teamNavigationDescriptors.map( + (descriptor) => descriptor.testId, +) + +export function createDescriptorSearch( + descriptor: LinkDescriptor, +): MaskingSearch { + return { + page: (descriptor.index % 9) + 1, + filter: `descriptor-${descriptor.index % 13}`, + layout: descriptor.index % 2 === 0 ? 'grid' : 'detail', + } +} + +export function createLinkState(descriptor: LinkDescriptor) { + return { + scenario: 'masking-rewrites', + key: descriptor.key, + kind: descriptor.kind, + } +} + +export function createLinkOptions(descriptor: LinkDescriptor) { + const baseOptions = { + replace: true, + resetScroll: false, + hashScrollIntoView: false, + state: createLinkState(descriptor), + } + + if (descriptor.kind === 'photo-detail') { + return { + ...baseOptions, + to: MASKING_ROUTE_PATHS.photoDetail, + params: { photoId: descriptor.photoId }, + search: createDescriptorSearch(descriptor), + } + } + + if (descriptor.kind === 'auto-masked-modal') { + return { + ...baseOptions, + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: descriptor.photoId }, + search: createDescriptorSearch(descriptor), + } + } + + if (descriptor.kind === 'explicit-masked-modal') { + return { + ...baseOptions, + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: descriptor.photoId }, + search: createDescriptorSearch(descriptor), + mask: { + to: MASKING_ROUTE_PATHS.photoDetail, + params: { photoId: descriptor.photoId }, + search: { + ...createDescriptorSearch(descriptor), + layout: 'detail', + }, + state: { + scenario: 'masking-rewrites', + key: descriptor.key, + mask: 'explicit-photo-detail', + }, + unmaskOnReload: true, + }, + } + } + + if (descriptor.kind === 'unmasked-modal') { + return { + ...baseOptions, + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: descriptor.photoId }, + search: createDescriptorSearch(descriptor), + mask: { + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: descriptor.photoId }, + search: createDescriptorSearch(descriptor), + state: { + scenario: 'masking-rewrites', + key: descriptor.key, + mask: 'visible-modal', + }, + }, + } + } + + if (descriptor.kind === 'legacy-settings') { + return { + ...baseOptions, + to: MASKING_ROUTE_PATHS.settingsProfile, + search: createDescriptorSearch(descriptor), + } + } + + return { + ...baseOptions, + to: MASKING_ROUTE_PATHS.teamProject, + params: { + teamId: descriptor.teamId, + projectId: descriptor.projectId, + }, + search: createDescriptorSearch(descriptor), + hash: descriptor.hash, + } +} + +export function createMaskingLinkActiveOptions(descriptor: LinkDescriptor) { + return { includeSearch: descriptor.kind !== 'team-project' } +} + +export function createBuildLocationOptions( + fromLocation: unknown, + descriptor: LinkDescriptor, +) { + return { + _fromLocation: fromLocation, + ...createLinkOptions(descriptor), + } +} + +export function createLinkLabel(descriptor: LinkDescriptor) { + if (descriptor.kind === 'photo-detail') { + return `Photo ${descriptor.photoId}` + } + + if (descriptor.kind === 'auto-masked-modal') { + return `Auto masked modal ${descriptor.photoId}` + } + + if (descriptor.kind === 'explicit-masked-modal') { + return `Explicit masked modal ${descriptor.photoId}` + } + + if (descriptor.kind === 'unmasked-modal') { + return `Visible modal ${descriptor.photoId}` + } + + if (descriptor.kind === 'legacy-settings') { + return `Legacy settings ${descriptor.index}` + } + + return `Team ${descriptor.teamId} project ${descriptor.projectId}` +} + +export function readVisiblePublicHref(location: BuiltLocationSnapshot) { + return location.maskedLocation?.publicHref ?? location.publicHref +} + +function createNavigationCycle(cycle: number): Array { + const firstPhotoId = photoIds[(cycle * 8 + 1) % photoIds.length]! + const secondPhotoId = photoIds[(cycle * 8 + 2) % photoIds.length]! + const unmaskedPhotoId = photoIds[(cycle * 8 + 3) % photoIds.length]! + const noSlashPhotoId = photoIds[(cycle * 8 + 4) % photoIds.length]! + const slashPhotoId = photoIds[(cycle * 8 + 5) % photoIds.length]! + const teamDescriptor = + teamNavigationDescriptors[cycle % teamNavigationDescriptors.length]! + + return [ + { + kind: 'navigate', + label: `public-photos-${cycle}`, + options: { + href: publicPhotosHref(createActionSearch(cycle, 'photos'), false), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + kind: 'navigate', + label: `auto-masked-modal-${cycle}`, + options: { + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: firstPhotoId }, + search: createActionSearch(cycle, 'auto-mask'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + kind: 'navigate', + label: `auto-masked-modal-next-${cycle}`, + options: { + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: secondPhotoId }, + search: createActionSearch(cycle, 'auto-mask-next'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + kind: 'navigate', + label: `unmasked-modal-${cycle}`, + options: { + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: unmaskedPhotoId }, + search: createActionSearch(cycle, 'visible-modal'), + mask: { + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: unmaskedPhotoId }, + search: createActionSearch(cycle, 'visible-modal-mask'), + state: { + scenario: 'masking-rewrites', + cycle, + mask: 'visible-modal', + }, + }, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + kind: 'navigate', + label: `legacy-settings-${cycle}`, + options: { + href: publicLegacySettingsHref( + createActionSearch(cycle, 'legacy-settings'), + cycle % 2 === 0, + ), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + kind: 'click', + label: `team-project-link-${cycle}`, + testId: teamDescriptor.testId, + }, + { + kind: 'navigate', + label: `trailing-no-slash-${cycle}`, + options: { + href: publicPhotoDetailHref( + noSlashPhotoId, + createActionSearch(cycle, 'no-slash'), + undefined, + false, + ), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + { + kind: 'navigate', + label: `trailing-slash-${cycle}`, + options: { + href: publicPhotoDetailHref( + slashPhotoId, + createActionSearch(cycle, 'slash'), + undefined, + true, + ), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }, + }, + ] +} + +const navigationSteps = Array.from( + { length: navigationCyclesPerRun }, + (_, cycle) => createNavigationCycle(cycle), +).flat() + +function getRouteMarker(container: ParentNode) { + return container.querySelector('[data-route-marker]') +} + +function assertCount( + container: ParentNode, + selector: string, + expected: number, +) { + const actual = container.querySelectorAll(selector).length + + if (actual !== expected) { + throw new Error(`Expected ${expected} ${selector} nodes, got ${actual}`) + } +} + +function assertRouteMarker( + container: ParentNode, + expected: RouteMarkerExpectation, +) { + const marker = getRouteMarker(container) + + if (!marker) { + throw new Error('Expected a masking rewrite route marker') + } + + if (marker.dataset.routeMarker !== expected.route) { + throw new Error( + `Expected route marker ${expected.route}, got ${marker.dataset.routeMarker}`, + ) + } + + const checks = [ + ['photoId', expected.photoId], + ['teamId', expected.teamId], + ['projectId', expected.projectId], + ] as const + + for (const [field, value] of checks) { + if (value !== undefined && marker.dataset[field] !== value) { + throw new Error( + `Expected route marker ${field} ${value}, got ${marker.dataset[field]}`, + ) + } + } +} + +function assertPublicInternalDifference( + location: BuiltLocationSnapshot, + expectedInternalPath: string, + expectedPublicPrefix: string, +) { + const visiblePublicHref = readVisiblePublicHref(location) + + if (!location.href.startsWith(expectedInternalPath)) { + throw new Error( + `Expected internal href to start with ${expectedInternalPath}, got ${location.href}`, + ) + } + + if (!visiblePublicHref.startsWith(expectedPublicPrefix)) { + throw new Error( + `Expected visible public href to start with ${expectedPublicPrefix}, got ${visiblePublicHref}`, + ) + } + + if (location.href === visiblePublicHref) { + throw new Error( + `Expected internal and public hrefs to differ: ${location.href}`, + ) + } + + if (!location.publicHref) { + throw new Error('Expected location.publicHref to be populated') + } +} + +function assertMaskedLocationState(location: BuiltLocationSnapshot) { + if (!location.maskedLocation) { + throw new Error('Expected a masked location') + } + + if (!location.maskedLocation.state.__tempLocation) { + throw new Error('Expected masked location temp-location state') + } +} + +function assertBuiltHrefTransforms(container: ParentNode) { + let maskedHrefCount = 0 + let legacyHrefCount = 0 + let differingHrefCount = 0 + + for (const node of container.querySelectorAll( + '[data-built-visible-href]', + )) { + const key = node.dataset.builtKey + const kind = node.dataset.builtKind + const internalHref = node.dataset.builtInternalHref + const visibleHref = node.dataset.builtVisibleHref + + if (!key || !kind || !internalHref || !visibleHref) { + throw new Error('Expected complete built href metadata') + } + + const link = container.querySelector( + `[data-mask-link-key="${key}"]`, + ) + const linkHref = link?.getAttribute('href') + + if (linkHref !== visibleHref) { + throw new Error( + `Href parity failed for ${key}: link=${linkHref} built=${visibleHref}`, + ) + } + + if (internalHref !== visibleHref) { + differingHrefCount += 1 + } + + if (kind === 'auto-masked-modal') { + maskedHrefCount += 1 + + if (!internalHref.includes('/modal') || visibleHref.includes('/modal')) { + throw new Error( + `Expected masked modal href pair, got internal=${internalHref} visible=${visibleHref}`, + ) + } + } + + if (kind === 'legacy-settings') { + legacyHrefCount += 1 + + if ( + !visibleHref.startsWith( + `${routerBasepath}${publicLocalePrefix}/legacy/profile`, + ) + ) { + throw new Error( + `Expected legacy settings public href, got ${visibleHref}`, + ) + } + } + } + + if (maskedHrefCount === 0) { + throw new Error('Expected at least one masked modal built href') + } + + if (legacyHrefCount === 0) { + throw new Error('Expected at least one legacy settings built href') + } + + if (differingHrefCount === 0) { + throw new Error('Expected at least one differing internal/public href pair') + } +} + +export function createMaskingRewritesWorkload( + framework: Framework, + mountTestApp: MountTestApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ + mountTestApp, + timeoutMs: 4_000, + }) + let stepIndex = 0 + + async function waitForScenarioReady() { + await lifecycle.waitForCounter( + () => + lifecycle.getContainer().querySelectorAll('[data-masking-link="true"]') + .length, + linkPanelExpectedCount, + { label: 'masking rewrite link panel render' }, + ) + await lifecycle.waitForCounter( + () => + lifecycle.getContainer().querySelectorAll('[data-built-visible-href]') + .length, + buildLocationExpectedCount, + { label: 'masking rewrite build href render' }, + ) + } + + async function prepareLinks() { + for (const testId of requiredNavigationLinkTestIds) { + await lifecycle.waitForLink(testId) + } + } + + async function before() { + stepIndex = 0 + await lifecycle.before() + await waitForScenarioReady() + await prepareLinks() + } + + async function runSteps(count: number) { + for (let index = 0; index < count; index++) { + const step = navigationSteps[stepIndex % navigationSteps.length]! + stepIndex += 1 + + if (step.kind === 'click') { + await lifecycle.click(step.testId, { + wait: 'rendered', + label: step.label, + }) + continue + } + + await lifecycle.navigate(step.options as NavigateOptions, { + wait: 'rendered', + label: step.label, + }) + } + } + + async function sanity() { + await before() + + try { + const container = lifecycle.getContainer() + const router = lifecycle.getRouter() + const firstMaskedPhotoId = photoIds[1]! + const teamDescriptor = teamNavigationDescriptors[0]! + + assertCount( + container, + '[data-masking-link="true"]', + linkPanelExpectedCount, + ) + assertCount( + container, + '[data-built-visible-href]', + buildLocationExpectedCount, + ) + assertRouteMarker(container, { route: MASKING_ROUTE_MARKERS.photos }) + assertPublicInternalDifference( + router.state.location as unknown as BuiltLocationSnapshot, + internalPhotosPath(), + `${routerBasepath}${publicLocalePrefix}${internalPhotosPath()}`, + ) + assertBuiltHrefTransforms(container) + + const builtMaskedLocation = router.buildLocation({ + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: firstMaskedPhotoId }, + replace: true, + } as never) as unknown as BuiltLocationSnapshot + + if (!builtMaskedLocation.maskedLocation) { + throw new Error('Expected route mask to build a masked location') + } + + assertPublicInternalDifference( + builtMaskedLocation, + internalPhotoModalPath(firstMaskedPhotoId), + publicPhotoDetailHref(firstMaskedPhotoId).replace(/\?.*$/, ''), + ) + + await lifecycle.navigate( + { + to: MASKING_ROUTE_PATHS.photoModal, + params: { photoId: firstMaskedPhotoId }, + search: createActionSearch(0, 'sanity-mask'), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + } as NavigateOptions, + { wait: 'rendered', label: 'sanity masked modal' }, + ) + assertRouteMarker(container, { + route: MASKING_ROUTE_MARKERS.photoModal, + photoId: firstMaskedPhotoId, + }) + assertPublicInternalDifference( + router.state.location as unknown as BuiltLocationSnapshot, + internalPhotoModalPath(firstMaskedPhotoId), + publicPhotoDetailHref(firstMaskedPhotoId).replace(/\?.*$/, ''), + ) + assertMaskedLocationState( + router.state.location as unknown as BuiltLocationSnapshot, + ) + + await lifecycle.navigate( + { + href: publicLegacySettingsHref( + createActionSearch(0, 'sanity-legacy'), + ), + replace: true, + resetScroll: false, + hashScrollIntoView: false, + } as NavigateOptions, + { wait: 'rendered', label: 'sanity legacy settings' }, + ) + assertRouteMarker(container, { + route: MASKING_ROUTE_MARKERS.settingsProfile, + }) + assertPublicInternalDifference( + router.state.location as unknown as BuiltLocationSnapshot, + internalSettingsProfilePath(), + `${routerBasepath}${publicLocalePrefix}/legacy/profile`, + ) + + await lifecycle.click(teamDescriptor.testId, { + wait: 'rendered', + label: 'sanity team project link', + }) + assertRouteMarker(container, { + route: MASKING_ROUTE_MARKERS.teamProject, + teamId: teamDescriptor.teamId, + projectId: teamDescriptor.projectId, + }) + assertPublicInternalDifference( + router.state.location as unknown as BuiltLocationSnapshot, + internalTeamProjectPath( + teamDescriptor.teamId, + teamDescriptor.projectId, + ), + publicTeamProjectHref( + teamDescriptor.teamId, + teamDescriptor.projectId, + ).replace(/\?.*$/, ''), + ) + + await runSteps(navigationStepsPerCycle) + assertRouteMarker(container, { + route: MASKING_ROUTE_MARKERS.photoDetail, + }) + } finally { + await lifecycle.after() + } + } + + return { + name: `client masking rewrites loop (${framework})`, + before, + run: () => runSteps(navigationsPerBenchRun), + sanity, + after: lifecycle.after, + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/project.json b/benchmarks/client-nav/scenarios/masking-rewrites/solid/project.json new file mode 100644 index 0000000000..952d4d6d35 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-masking-rewrites-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/setup.ts b/benchmarks/client-nav/scenarios/masking-rewrites/solid/setup.ts new file mode 100644 index 0000000000..2c1ced7ff5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createMaskingRewritesWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createMaskingRewritesWorkload( + 'solid', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/masking-rewrites/solid/speed.bench.ts new file mode 100644 index 0000000000..87e687c988 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav masking-rewrites', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/masking-rewrites/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/app.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/app.tsx new file mode 100644 index 0000000000..2b8836e72b --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/app.tsx @@ -0,0 +1,27 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { patchMissingScrollToGlobal } from '../../shared.ts' +import { getRouter } from './router' + +export function mountTestApp(container: HTMLDivElement) { + const restoreScrollTo = patchMissingScrollToGlobal() + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + try { + dispose() + } finally { + restoreScrollTo() + } + }, + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/link-panel.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/link-panel.tsx new file mode 100644 index 0000000000..c16c66e793 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/link-panel.tsx @@ -0,0 +1,74 @@ +import { For, createMemo } from 'solid-js' +import { Link, useRouter, useRouterState } from '@tanstack/solid-router' +import { + buildLocationDescriptors, + createBuildLocationOptions, + createLinkLabel, + createLinkOptions, + createMaskingLinkActiveOptions, + linkDescriptors, + readVisiblePublicHref, + type BuiltLocationSnapshot, + type LinkDescriptor, +} from '../../shared.ts' + +function PanelLink(props: { descriptor: LinkDescriptor }) { + return ( + + {createLinkLabel(props.descriptor)} + + ) +} + +function BuildLocationProbe(props: { descriptor: LinkDescriptor }) { + const router = useRouter() + const locationHref = useRouterState({ + select: (state) => state.location.href, + }) + const builtLocation = createMemo(() => { + void locationHref() + + return router.buildLocation( + createBuildLocationOptions( + router.state.location, + props.descriptor, + ) as any, + ) as unknown as BuiltLocationSnapshot + }) + const visiblePublicHref = createMemo(() => + readVisiblePublicHref(builtLocation()), + ) + + return ( + + {visiblePublicHref()} + + ) +} + +export function LinkPanel() { + return ( +
+ + {(descriptor) => } + + + {(descriptor) => } + +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routeTree.tsx new file mode 100644 index 0000000000..e6290fe33c --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routeTree.tsx @@ -0,0 +1,21 @@ +import { createRouteMask } from '@tanstack/solid-router' +import { photoModalMaskOptions } from '../../shared.ts' +import { rootRoute } from './routes/__root' +import { photoDetailRoute } from './routes/photos.$photoId' +import { photoModalRoute } from './routes/photos.$photoId.modal' +import { photosRoute } from './routes/photos' +import { settingsProfileRoute } from './routes/settings.profile' +import { teamProjectRoute } from './routes/teams.$teamId.projects.$projectId' + +export const routeTree = rootRoute.addChildren([ + photosRoute, + photoDetailRoute, + photoModalRoute, + settingsProfileRoute, + teamProjectRoute, +]) + +export const photoModalMask = createRouteMask({ + routeTree, + ...photoModalMaskOptions, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/router.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/router.tsx new file mode 100644 index 0000000000..44ae9cfd6e --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/router.tsx @@ -0,0 +1,26 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { + createMaskingRewrite, + initialPublicHref, + routerBasepath, +} from '../../shared.ts' +import { photoModalMask, routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialPublicHref], + }), + basepath: routerBasepath, + rewrite: createMaskingRewrite(), + trailingSlash: 'never', + routeTree, + routeMasks: [photoModalMask], + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..b0408182f3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/__root.tsx @@ -0,0 +1,19 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' +import { normalizeMaskingSearch } from '../../../shared.ts' +import { LinkPanel } from '../link-panel' + +export const rootRoute = createRootRoute({ + validateSearch: normalizeMaskingSearch, + component: Root, +}) + +function Root() { + return ( + <> + +
+ +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.$photoId.modal.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.$photoId.modal.tsx new file mode 100644 index 0000000000..9b8c118329 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.$photoId.modal.tsx @@ -0,0 +1,20 @@ +import { createRoute } from '@tanstack/solid-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const photoModalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photoModal, + component: PhotoModalPage, +}) + +function PhotoModalPage() { + const params = photoModalRoute.useParams() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.$photoId.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.$photoId.tsx new file mode 100644 index 0000000000..a86094d8ca --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.$photoId.tsx @@ -0,0 +1,20 @@ +import { createRoute } from '@tanstack/solid-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const photoDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photoDetail, + component: PhotoDetailPage, +}) + +function PhotoDetailPage() { + const params = photoDetailRoute.useParams() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.tsx new file mode 100644 index 0000000000..fae287732d --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/photos.tsx @@ -0,0 +1,13 @@ +import { createRoute } from '@tanstack/solid-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const photosRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photos, + component: PhotosPage, +}) + +function PhotosPage() { + return
+} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/settings.profile.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/settings.profile.tsx new file mode 100644 index 0000000000..845f16f676 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/settings.profile.tsx @@ -0,0 +1,13 @@ +import { createRoute } from '@tanstack/solid-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const settingsProfileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.settingsProfile, + component: SettingsProfilePage, +}) + +function SettingsProfilePage() { + return
+} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/teams.$teamId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/teams.$teamId.projects.$projectId.tsx new file mode 100644 index 0000000000..d5ccc99126 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/src/routes/teams.$teamId.projects.$projectId.tsx @@ -0,0 +1,21 @@ +import { createRoute } from '@tanstack/solid-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +export const teamProjectRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.teamProject, + component: TeamProjectPage, +}) + +function TeamProjectPage() { + const params = teamProjectRoute.useParams() + + return ( +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/tsconfig.json b/benchmarks/client-nav/scenarios/masking-rewrites/solid/tsconfig.json new file mode 100644 index 0000000000..b549cd9fe8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/solid/vite.config.ts b/benchmarks/client-nav/scenarios/masking-rewrites/solid/vite.config.ts new file mode 100644 index 0000000000..05d870c748 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/solid/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav masking-rewrites (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/project.json b/benchmarks/client-nav/scenarios/masking-rewrites/vue/project.json new file mode 100644 index 0000000000..ab5659d94b --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-masking-rewrites-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/setup.ts b/benchmarks/client-nav/scenarios/masking-rewrites/vue/setup.ts new file mode 100644 index 0000000000..08a4fe6e59 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createMaskingRewritesWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createMaskingRewritesWorkload( + 'vue', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/masking-rewrites/vue/speed.bench.ts new file mode 100644 index 0000000000..87e687c988 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav masking-rewrites', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/masking-rewrites/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/app.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/app.tsx new file mode 100644 index 0000000000..0a462dbd63 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/app.tsx @@ -0,0 +1,31 @@ +import { RouterProvider } from '@tanstack/vue-router' +import { createApp } from 'vue' +import { patchMissingScrollToGlobal } from '../../shared.ts' +import { getRouter } from './router' + +export function mountTestApp(container: HTMLDivElement) { + const restoreScrollTo = patchMissingScrollToGlobal() + const router = getRouter() + const app = createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + try { + app.unmount() + } finally { + restoreScrollTo() + } + }, + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/link-panel.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/link-panel.tsx new file mode 100644 index 0000000000..6069b9d276 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/link-panel.tsx @@ -0,0 +1,91 @@ +import * as Vue from 'vue' +import { Link, useRouter, useRouterState } from '@tanstack/vue-router' +import { + buildLocationDescriptors, + createBuildLocationOptions, + createLinkLabel, + createLinkOptions, + createMaskingLinkActiveOptions, + linkDescriptors, + readVisiblePublicHref, + type BuiltLocationSnapshot, + type LinkDescriptor, +} from '../../shared.ts' + +const PanelLink = Vue.defineComponent({ + props: { + descriptor: { + type: Object as Vue.PropType, + required: true, + }, + }, + setup(props) { + return () => ( + + {createLinkLabel(props.descriptor)} + + ) + }, +}) + +const BuildLocationProbe = Vue.defineComponent({ + props: { + descriptor: { + type: Object as Vue.PropType, + required: true, + }, + }, + setup(props) { + const router = useRouter() + const locationHref = useRouterState({ + select: (state) => state.location.href, + }) + + return () => { + void locationHref.value + + const builtLocation = router.buildLocation( + createBuildLocationOptions( + router.state.location, + props.descriptor, + ) as any, + ) as unknown as BuiltLocationSnapshot + const visiblePublicHref = readVisiblePublicHref(builtLocation) + + return ( + + {visiblePublicHref} + + ) + } + }, +}) + +export const LinkPanel = Vue.defineComponent({ + setup() { + return () => ( +
+ {linkDescriptors.map((descriptor) => ( + + ))} + {buildLocationDescriptors.map((descriptor) => ( + + ))} +
+ ) + }, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routeTree.tsx new file mode 100644 index 0000000000..20ce43b121 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routeTree.tsx @@ -0,0 +1,21 @@ +import { createRouteMask } from '@tanstack/vue-router' +import { photoModalMaskOptions } from '../../shared.ts' +import { rootRoute } from './routes/__root' +import { photoDetailRoute } from './routes/photos.$photoId' +import { photoModalRoute } from './routes/photos.$photoId.modal' +import { photosRoute } from './routes/photos' +import { settingsProfileRoute } from './routes/settings.profile' +import { teamProjectRoute } from './routes/teams.$teamId.projects.$projectId' + +export const routeTree = rootRoute.addChildren([ + photosRoute, + photoDetailRoute, + photoModalRoute, + settingsProfileRoute, + teamProjectRoute, +]) + +export const photoModalMask = createRouteMask({ + routeTree, + ...photoModalMaskOptions, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/router.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/router.tsx new file mode 100644 index 0000000000..570839eb79 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/router.tsx @@ -0,0 +1,26 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { + createMaskingRewrite, + initialPublicHref, + routerBasepath, +} from '../../shared.ts' +import { photoModalMask, routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [initialPublicHref], + }), + basepath: routerBasepath, + rewrite: createMaskingRewrite(), + trailingSlash: 'never', + routeTree, + routeMasks: [photoModalMask], + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..b0d11c6ec0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/__root.tsx @@ -0,0 +1,22 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' +import { normalizeMaskingSearch } from '../../../shared.ts' +import { LinkPanel } from '../link-panel' + +const Root = Vue.defineComponent({ + setup() { + return () => ( + <> + +
+ +
+ + ) + }, +}) + +export const rootRoute = createRootRoute({ + validateSearch: normalizeMaskingSearch, + component: Root, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.$photoId.modal.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.$photoId.modal.tsx new file mode 100644 index 0000000000..572b8add38 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.$photoId.modal.tsx @@ -0,0 +1,23 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +const PhotoModalPage = Vue.defineComponent({ + setup() { + const params = photoModalRoute.useParams() + + return () => ( +
+ ) + }, +}) + +export const photoModalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photoModal, + component: PhotoModalPage, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.$photoId.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.$photoId.tsx new file mode 100644 index 0000000000..ca62a4ef51 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.$photoId.tsx @@ -0,0 +1,23 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +const PhotoDetailPage = Vue.defineComponent({ + setup() { + const params = photoDetailRoute.useParams() + + return () => ( +
+ ) + }, +}) + +export const photoDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photoDetail, + component: PhotoDetailPage, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.tsx new file mode 100644 index 0000000000..7bb1c54e15 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/photos.tsx @@ -0,0 +1,16 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +const PhotosPage = Vue.defineComponent({ + setup() { + return () =>
+ }, +}) + +export const photosRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.photos, + component: PhotosPage, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/settings.profile.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/settings.profile.tsx new file mode 100644 index 0000000000..520e66d4e4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/settings.profile.tsx @@ -0,0 +1,18 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +const SettingsProfilePage = Vue.defineComponent({ + setup() { + return () => ( +
+ ) + }, +}) + +export const settingsProfileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.settingsProfile, + component: SettingsProfilePage, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/teams.$teamId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/teams.$teamId.projects.$projectId.tsx new file mode 100644 index 0000000000..69b883df58 --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/src/routes/teams.$teamId.projects.$projectId.tsx @@ -0,0 +1,24 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { MASKING_ROUTE_MARKERS, MASKING_ROUTE_PATHS } from '../../../shared.ts' +import { rootRoute } from './__root' + +const TeamProjectPage = Vue.defineComponent({ + setup() { + const params = teamProjectRoute.useParams() + + return () => ( +
+ ) + }, +}) + +export const teamProjectRoute = createRoute({ + getParentRoute: () => rootRoute, + path: MASKING_ROUTE_PATHS.teamProject, + component: TeamProjectPage, +}) diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/tsconfig.json b/benchmarks/client-nav/scenarios/masking-rewrites/vue/tsconfig.json new file mode 100644 index 0000000000..24bdb3e3cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/masking-rewrites/vue/vite.config.ts b/benchmarks/client-nav/scenarios/masking-rewrites/vue/vite.config.ts new file mode 100644 index 0000000000..5c159f373c --- /dev/null +++ b/benchmarks/client-nav/scenarios/masking-rewrites/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav masking-rewrites (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/flame-jsdom.ts b/benchmarks/client-nav/scenarios/outlets-remounts/flame-jsdom.ts new file mode 100644 index 0000000000..076b649d0b --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/flame-jsdom.ts @@ -0,0 +1,27 @@ +import { window } from '../../jsdom.ts' + +export function installOutletsRemountsFlameGlobals() { + const hadScrollTo = 'scrollTo' in globalThis + const previousScrollTo = globalThis.scrollTo + + Object.defineProperty(globalThis, 'scrollTo', { + configurable: true, + value: window.scrollTo.bind(window), + writable: true, + }) + + return () => { + if (hadScrollTo) { + Object.defineProperty(globalThis, 'scrollTo', { + configurable: true, + value: previousScrollTo, + writable: true, + }) + return + } + + Reflect.deleteProperty(globalThis, 'scrollTo') + } +} + +export { window } diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/project.json b/benchmarks/client-nav/scenarios/outlets-remounts/react/project.json new file mode 100644 index 0000000000..e1fcff00cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-outlets-remounts-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/setup.ts b/benchmarks/client-nav/scenarios/outlets-remounts/react/setup.ts new file mode 100644 index 0000000000..0542fd0d5c --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createOutletsRemountsWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const app = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createOutletsRemountsWorkload('react', app) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/speed.bench.ts b/benchmarks/client-nav/scenarios/outlets-remounts/react/speed.bench.ts new file mode 100644 index 0000000000..eb402bfd12 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav outlets-remounts', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/speed.flame.ts b/benchmarks/client-nav/scenarios/outlets-remounts/react/speed.flame.ts new file mode 100644 index 0000000000..257b8d40a4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/speed.flame.ts @@ -0,0 +1,19 @@ +import { installOutletsRemountsFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installOutletsRemountsFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/app.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/app.tsx new file mode 100644 index 0000000000..c77ece1383 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/app.tsx @@ -0,0 +1,29 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export { + getOutletsRemountsComponentCounters, + getOutletsRemountsLifecycleCounters, + resetOutletsRemountsCounters, +} from './outletsRemountsRuntime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/outletsRemountsRuntime.ts b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/outletsRemountsRuntime.ts new file mode 100644 index 0000000000..35221b53d3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/outletsRemountsRuntime.ts @@ -0,0 +1,11 @@ +import { createOutletsRemountsRuntime } from '../../shared' + +export const { + createRouteLifecycleOptions, + getOutletsRemountsComponentCounters, + getOutletsRemountsComponentRenderCount, + getOutletsRemountsLifecycleCounters, + recordComponentMount, + recordComponentRender, + resetOutletsRemountsCounters, +} = createOutletsRemountsRuntime() diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routeShell.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routeShell.tsx new file mode 100644 index 0000000000..9f7e11eb36 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routeShell.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { useState } from 'react' +import { + getOutletsRemountsComponentRenderCount, + recordComponentMount, + recordComponentRender, +} from './outletsRemountsRuntime' +import type { OutletsRemountsComponentId } from '../../shared' + +function useRouteInstrumentation( + routeId: OutletsRemountsComponentId, + marker: string, +) { + const [mountIndex] = useState(() => recordComponentMount(routeId, marker)) + const checksum = recordComponentRender(routeId, marker) + + return { + checksum, + mountIndex, + renderCount: getOutletsRemountsComponentRenderCount(routeId), + } +} + +export function RouteShell(props: { + routeId: OutletsRemountsComponentId + marker: string + children?: ReactNode +}) { + const instrumentation = useRouteInstrumentation(props.routeId, props.marker) + + return ( +
+ + {props.children} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routeTree.tsx new file mode 100644 index 0000000000..9ca20bdbf3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routeTree.tsx @@ -0,0 +1,17 @@ +import { rootRoute } from './routes/__root' +import { workspaceRoute } from './routes/workspace' +import { orgRoute } from './routes/workspace.$orgId' +import { projectsRoute } from './routes/workspace.$orgId.projects' +import { projectRoute } from './routes/workspace.$orgId.projects.$projectId' +import { boardRoute } from './routes/workspace.$orgId.projects.$projectId.boards.$boardId' +import { cardRoute } from './routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId' + +export const routeTree = rootRoute.addChildren([ + workspaceRoute.addChildren([ + orgRoute.addChildren([ + projectsRoute.addChildren([ + projectRoute.addChildren([boardRoute.addChildren([cardRoute])]), + ]), + ]), + ]), +]) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/router.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/router.tsx new file mode 100644 index 0000000000..987755af41 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { outletsRemountsInitialPath } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [outletsRemountsInitialPath], + }), + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/__root.tsx new file mode 100644 index 0000000000..edc04c397b --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx new file mode 100644 index 0000000000..1a14deabfc --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx @@ -0,0 +1,35 @@ +import { createRoute } from '@tanstack/react-router' +import { createOutletsRemountsMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { boardRoute } from './workspace.$orgId.projects.$projectId.boards.$boardId' + +function CardPage() { + const params = cardRoute.useParams() + const marker = createOutletsRemountsMarker({ + kind: 'card', + orgId: params.orgId, + projectId: params.projectId, + boardId: params.boardId, + cardId: params.cardId, + }) + + return ( + +
+ + ) +} + +export const cardRoute = createRoute({ + getParentRoute: () => boardRoute, + path: 'cards/$cardId', + ...createRouteLifecycleOptions('card'), + component: CardPage, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx new file mode 100644 index 0000000000..83cbc1ce3e --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx @@ -0,0 +1,26 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { createOutletsRemountsBoardMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { projectRoute } from './workspace.$orgId.projects.$projectId' + +function BoardLayout() { + const params = boardRoute.useParams() + const marker = createOutletsRemountsBoardMarker(params) + + return ( + + + + ) +} + +export const boardRoute = createRoute({ + getParentRoute: () => projectRoute, + path: 'boards/$boardId', + ...createRouteLifecycleOptions('board'), + remountDeps: ({ params }) => ({ + boardId: params.boardId, + }), + component: BoardLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.tsx new file mode 100644 index 0000000000..4907846c0c --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.$projectId.tsx @@ -0,0 +1,26 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { createOutletsRemountsProjectMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { projectsRoute } from './workspace.$orgId.projects' + +function ProjectLayout() { + const params = projectRoute.useParams() + const marker = createOutletsRemountsProjectMarker(params) + + return ( + + + + ) +} + +export const projectRoute = createRoute({ + getParentRoute: () => projectsRoute, + path: '$projectId', + ...createRouteLifecycleOptions('project'), + remountDeps: ({ params }) => ({ + projectId: params.projectId, + }), + component: ProjectLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.tsx new file mode 100644 index 0000000000..3e18da18de --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.projects.tsx @@ -0,0 +1,23 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { createOutletsRemountsProjectsMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { orgRoute } from './workspace.$orgId' + +function ProjectsLayout() { + const params = projectsRoute.useParams() + const marker = createOutletsRemountsProjectsMarker(params) + + return ( + + + + ) +} + +export const projectsRoute = createRoute({ + getParentRoute: () => orgRoute, + path: 'projects', + ...createRouteLifecycleOptions('projects'), + component: ProjectsLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.tsx new file mode 100644 index 0000000000..99f0466591 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.$orgId.tsx @@ -0,0 +1,27 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { createOutletsRemountsMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { workspaceRoute } from './workspace' + +function OrgLayout() { + const params = orgRoute.useParams() + const marker = createOutletsRemountsMarker({ + kind: 'org', + orgId: params.orgId, + }) + + return ( + +
+ + + ) +} + +export const orgRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: '$orgId', + ...createRouteLifecycleOptions('org'), + component: OrgLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.tsx new file mode 100644 index 0000000000..fa427d355e --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/src/routes/workspace.tsx @@ -0,0 +1,21 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { outletsRemountsScenarioSlug } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { rootRoute } from './__root' + +function WorkspaceLayout() { + return ( + +
+ + + ) +} + +export const workspaceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/workspace', + ...createRouteLifecycleOptions('workspace'), + component: WorkspaceLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/tsconfig.json b/benchmarks/client-nav/scenarios/outlets-remounts/react/tsconfig.json new file mode 100644 index 0000000000..2c5e42f330 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.tsx", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/react/vite.config.ts b/benchmarks/client-nav/scenarios/outlets-remounts/react/vite.config.ts new file mode 100644 index 0000000000..ac473d86ff --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/react/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav outlets-remounts (react)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/shared.ts b/benchmarks/client-nav/scenarios/outlets-remounts/shared.ts new file mode 100644 index 0000000000..e436d1252a --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/shared.ts @@ -0,0 +1,359 @@ +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' + +export const outletsRemountsScenarioSlug = 'outlets-remounts' +export const outletsRemountsCycleCount = 3 +export const outletsRemountsActionsPerCycle = 6 +export const outletsRemountsActionCount = + outletsRemountsCycleCount * outletsRemountsActionsPerCycle + +export type OutletsRemountsRouteId = + | 'workspace' + | 'org' + | 'projects' + | 'project' + | 'board' + | 'card' + +export type OutletsRemountsComponentId = OutletsRemountsRouteId + +export type OutletsRemountsLifecycleHook = 'enter' | 'stay' | 'leave' + +export interface OutletsRemountsLifecycleCounter { + enter: number + stay: number + leave: number +} + +export type OutletsRemountsLifecycleCounters = Record< + OutletsRemountsRouteId, + OutletsRemountsLifecycleCounter +> + +export interface OutletsRemountsComponentCounter { + mounts: number + renders: number +} + +export type OutletsRemountsComponentCounters = Record< + OutletsRemountsComponentId, + OutletsRemountsComponentCounter +> + +export interface OutletsRemountsCardTarget { + kind: 'card' + orgId: string + projectId: string + boardId: string + cardId: string +} + +export interface OutletsRemountsOrgTarget { + kind: 'org' + orgId: string +} + +export type OutletsRemountsTarget = + | OutletsRemountsCardTarget + | OutletsRemountsOrgTarget + +export interface OutletsRemountsLocation { + target: OutletsRemountsTarget + to: + | '/workspace/$orgId' + | '/workspace/$orgId/projects/$projectId/boards/$boardId/cards/$cardId' + params: Record + marker: string +} + +export type OutletsRemountsParams = Partial<{ + orgId: string + projectId: string + boardId: string + cardId: string +}> + +export const outletsRemountsRouteIds = [ + 'workspace', + 'org', + 'projects', + 'project', + 'board', + 'card', +] as const satisfies Array + +export const outletsRemountsInitialTarget: OutletsRemountsCardTarget = { + kind: 'card', + orgId: 'org-initial', + projectId: 'project-initial', + boardId: 'board-initial', + cardId: 'card-initial', +} + +export const outletsRemountsInitialLocation = createOutletsRemountsLocation( + outletsRemountsInitialTarget, +) +export const outletsRemountsInitialPath = buildOutletsRemountsPath( + outletsRemountsInitialTarget, +) + +export function createEmptyOutletsRemountsLifecycleCounters(): OutletsRemountsLifecycleCounters { + return Object.fromEntries( + outletsRemountsRouteIds.map((routeId) => [ + routeId, + { + enter: 0, + stay: 0, + leave: 0, + }, + ]), + ) as OutletsRemountsLifecycleCounters +} + +export function createEmptyOutletsRemountsComponentCounters(): OutletsRemountsComponentCounters { + return Object.fromEntries( + outletsRemountsRouteIds.map((routeId) => [ + routeId, + { + mounts: 0, + renders: 0, + }, + ]), + ) as OutletsRemountsComponentCounters +} + +export function cloneOutletsRemountsLifecycleCounters( + counters: OutletsRemountsLifecycleCounters, +): OutletsRemountsLifecycleCounters { + return Object.fromEntries( + outletsRemountsRouteIds.map((routeId) => [ + routeId, + { ...counters[routeId] }, + ]), + ) as OutletsRemountsLifecycleCounters +} + +export function cloneOutletsRemountsComponentCounters( + counters: OutletsRemountsComponentCounters, +): OutletsRemountsComponentCounters { + return Object.fromEntries( + outletsRemountsRouteIds.map((routeId) => [ + routeId, + { ...counters[routeId] }, + ]), + ) as OutletsRemountsComponentCounters +} + +export function readOutletsRemountsParam( + params: OutletsRemountsParams, + key: keyof OutletsRemountsParams, +) { + const value = params[key] + + if (typeof value === 'string') { + return value + } + + return '' +} + +export function buildOutletsRemountsPath(target: OutletsRemountsTarget) { + if (target.kind === 'org') { + return `/workspace/${target.orgId}` + } + + return `/workspace/${target.orgId}/projects/${target.projectId}/boards/${target.boardId}/cards/${target.cardId}` +} + +export function createOutletsRemountsMarker(target: OutletsRemountsTarget) { + if (target.kind === 'org') { + return `org:${target.orgId}` + } + + return [ + 'card', + target.orgId, + target.projectId, + target.boardId, + target.cardId, + ].join(':') +} + +export function createOutletsRemountsProjectsMarker( + params: OutletsRemountsParams, +) { + return `projects:${readOutletsRemountsParam(params, 'orgId')}` +} + +export function createOutletsRemountsProjectMarker( + params: OutletsRemountsParams, +) { + return [ + 'project', + readOutletsRemountsParam(params, 'orgId'), + readOutletsRemountsParam(params, 'projectId'), + ].join(':') +} + +export function createOutletsRemountsBoardMarker( + params: OutletsRemountsParams, +) { + return [ + 'board', + readOutletsRemountsParam(params, 'orgId'), + readOutletsRemountsParam(params, 'projectId'), + readOutletsRemountsParam(params, 'boardId'), + ].join(':') +} + +export function createOutletsRemountsLocation( + target: OutletsRemountsTarget, +): OutletsRemountsLocation { + if (target.kind === 'org') { + return { + target, + to: '/workspace/$orgId', + params: { + orgId: target.orgId, + }, + marker: createOutletsRemountsMarker(target), + } + } + + return { + target, + to: '/workspace/$orgId/projects/$projectId/boards/$boardId/cards/$cardId', + params: { + orgId: target.orgId, + projectId: target.projectId, + boardId: target.boardId, + cardId: target.cardId, + }, + marker: createOutletsRemountsMarker(target), + } +} + +export function createOutletsRemountsLocations() { + const random = createDeterministicRandom(0x5eed_0909) + const locations: Array = [] + + for (let cycle = 0; cycle < outletsRemountsCycleCount; cycle++) { + const orgA = `org-${cycle}-${randomSegment(random)}` + const orgB = `org-${cycle}-${randomSegment(random)}` + const projectA = `project-${cycle}-${randomSegment(random)}` + const projectB = `project-${cycle}-${randomSegment(random)}` + const boardA = `board-${cycle}-${randomSegment(random)}` + const boardB = `board-${cycle}-${randomSegment(random)}` + const cardA = `card-${cycle}-${randomSegment(random)}` + const cardB = `card-${cycle}-${randomSegment(random)}` + const cardC = `card-${cycle}-${randomSegment(random)}` + const cardD = `card-${cycle}-${randomSegment(random)}` + + locations.push( + createOutletsRemountsLocation({ + kind: 'card', + orgId: orgA, + projectId: projectA, + boardId: boardA, + cardId: cardA, + }), + createOutletsRemountsLocation({ + kind: 'card', + orgId: orgA, + projectId: projectA, + boardId: boardA, + cardId: cardB, + }), + createOutletsRemountsLocation({ + kind: 'card', + orgId: orgA, + projectId: projectA, + boardId: boardB, + cardId: cardC, + }), + createOutletsRemountsLocation({ + kind: 'card', + orgId: orgA, + projectId: projectB, + boardId: boardA, + cardId: cardD, + }), + createOutletsRemountsLocation({ + kind: 'org', + orgId: orgB, + }), + createOutletsRemountsLocation({ + kind: 'card', + orgId: orgA, + projectId: projectA, + boardId: boardA, + cardId: cardA, + }), + ) + } + + return locations +} + +export function runOutletsRemountsComputation(seed: string, rounds = 18) { + let value = seed.length * 17 + + for (let index = 0; index < seed.length; index++) { + value = (value * 33 + seed.charCodeAt(index)) >>> 0 + } + + for (let index = 0; index < rounds; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function createOutletsRemountsRuntime() { + let lifecycleCounters = createEmptyOutletsRemountsLifecycleCounters() + let componentCounters = createEmptyOutletsRemountsComponentCounters() + + function recordLifecycle( + routeId: OutletsRemountsRouteId, + hook: OutletsRemountsLifecycleHook, + ) { + lifecycleCounters[routeId][hook] += 1 + void runOutletsRemountsComputation(`${routeId}:${hook}`) + } + + return { + resetOutletsRemountsCounters() { + lifecycleCounters = createEmptyOutletsRemountsLifecycleCounters() + componentCounters = createEmptyOutletsRemountsComponentCounters() + }, + getOutletsRemountsLifecycleCounters(): OutletsRemountsLifecycleCounters { + return cloneOutletsRemountsLifecycleCounters(lifecycleCounters) + }, + getOutletsRemountsComponentCounters(): OutletsRemountsComponentCounters { + return cloneOutletsRemountsComponentCounters(componentCounters) + }, + createRouteLifecycleOptions(routeId: OutletsRemountsRouteId) { + return { + onEnter: () => recordLifecycle(routeId, 'enter'), + onStay: () => recordLifecycle(routeId, 'stay'), + onLeave: () => recordLifecycle(routeId, 'leave'), + } + }, + recordComponentMount(routeId: OutletsRemountsComponentId, marker: string) { + componentCounters[routeId].mounts += 1 + void runOutletsRemountsComputation(`${routeId}:mount:${marker}`) + return componentCounters[routeId].mounts + }, + recordComponentRender(routeId: OutletsRemountsComponentId, marker: string) { + componentCounters[routeId].renders += 1 + return runOutletsRemountsComputation(`${routeId}:render:${marker}`, 10) + }, + getOutletsRemountsComponentRenderCount( + routeId: OutletsRemountsComponentId, + ) { + return componentCounters[routeId].renders + }, + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/project.json b/benchmarks/client-nav/scenarios/outlets-remounts/solid/project.json new file mode 100644 index 0000000000..879d7a3444 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-outlets-remounts-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/setup.ts b/benchmarks/client-nav/scenarios/outlets-remounts/solid/setup.ts new file mode 100644 index 0000000000..9e8382443b --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createOutletsRemountsWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const app = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createOutletsRemountsWorkload('solid', app) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/outlets-remounts/solid/speed.bench.ts new file mode 100644 index 0000000000..eb402bfd12 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav outlets-remounts', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/outlets-remounts/solid/speed.flame.ts new file mode 100644 index 0000000000..257b8d40a4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/speed.flame.ts @@ -0,0 +1,19 @@ +import { installOutletsRemountsFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installOutletsRemountsFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/app.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/app.tsx new file mode 100644 index 0000000000..3c044c5367 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/app.tsx @@ -0,0 +1,27 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { getRouter } from './router' + +export { + getOutletsRemountsComponentCounters, + getOutletsRemountsLifecycleCounters, + resetOutletsRemountsCounters, +} from './outletsRemountsRuntime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/outletsRemountsRuntime.ts b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/outletsRemountsRuntime.ts new file mode 100644 index 0000000000..35221b53d3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/outletsRemountsRuntime.ts @@ -0,0 +1,11 @@ +import { createOutletsRemountsRuntime } from '../../shared' + +export const { + createRouteLifecycleOptions, + getOutletsRemountsComponentCounters, + getOutletsRemountsComponentRenderCount, + getOutletsRemountsLifecycleCounters, + recordComponentMount, + recordComponentRender, + resetOutletsRemountsCounters, +} = createOutletsRemountsRuntime() diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routeShell.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routeShell.tsx new file mode 100644 index 0000000000..3ce0d2213b --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routeShell.tsx @@ -0,0 +1,34 @@ +import { createRenderEffect, type JSX } from 'solid-js' +import { + getOutletsRemountsComponentRenderCount, + recordComponentMount, + recordComponentRender, +} from './outletsRemountsRuntime' +import type { OutletsRemountsComponentId } from '../../shared' + +export function RouteShell(props: { + routeId: OutletsRemountsComponentId + marker: () => string + children?: JSX.Element +}) { + const mountIndex = recordComponentMount(props.routeId, props.marker()) + let checksum = 0 + + createRenderEffect(() => { + checksum = recordComponentRender(props.routeId, props.marker()) + }) + + return ( +
+ + {props.children} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routeTree.tsx new file mode 100644 index 0000000000..9ca20bdbf3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routeTree.tsx @@ -0,0 +1,17 @@ +import { rootRoute } from './routes/__root' +import { workspaceRoute } from './routes/workspace' +import { orgRoute } from './routes/workspace.$orgId' +import { projectsRoute } from './routes/workspace.$orgId.projects' +import { projectRoute } from './routes/workspace.$orgId.projects.$projectId' +import { boardRoute } from './routes/workspace.$orgId.projects.$projectId.boards.$boardId' +import { cardRoute } from './routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId' + +export const routeTree = rootRoute.addChildren([ + workspaceRoute.addChildren([ + orgRoute.addChildren([ + projectsRoute.addChildren([ + projectRoute.addChildren([boardRoute.addChildren([cardRoute])]), + ]), + ]), + ]), +]) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/router.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/router.tsx new file mode 100644 index 0000000000..d5b5fef744 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { outletsRemountsInitialPath } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [outletsRemountsInitialPath], + }), + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..1e9054643e --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx new file mode 100644 index 0000000000..436e074d8b --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx @@ -0,0 +1,36 @@ +import { createRoute } from '@tanstack/solid-router' +import { createOutletsRemountsMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { boardRoute } from './workspace.$orgId.projects.$projectId.boards.$boardId' + +function CardPage() { + const params = cardRoute.useParams() + const marker = () => + createOutletsRemountsMarker({ + kind: 'card', + orgId: params().orgId, + projectId: params().projectId, + boardId: params().boardId, + cardId: params().cardId, + }) + + return ( + +
+ + ) +} + +export const cardRoute = createRoute({ + getParentRoute: () => boardRoute, + path: 'cards/$cardId', + ...createRouteLifecycleOptions('card'), + component: CardPage, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx new file mode 100644 index 0000000000..7ad23ccda1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx @@ -0,0 +1,26 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { createOutletsRemountsBoardMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { projectRoute } from './workspace.$orgId.projects.$projectId' + +function BoardLayout() { + const params = boardRoute.useParams() + const marker = () => createOutletsRemountsBoardMarker(params()) + + return ( + + + + ) +} + +export const boardRoute = createRoute({ + getParentRoute: () => projectRoute, + path: 'boards/$boardId', + ...createRouteLifecycleOptions('board'), + remountDeps: ({ params }) => ({ + boardId: params.boardId, + }), + component: BoardLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.tsx new file mode 100644 index 0000000000..346ff77375 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.$projectId.tsx @@ -0,0 +1,26 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { createOutletsRemountsProjectMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { projectsRoute } from './workspace.$orgId.projects' + +function ProjectLayout() { + const params = projectRoute.useParams() + const marker = () => createOutletsRemountsProjectMarker(params()) + + return ( + + + + ) +} + +export const projectRoute = createRoute({ + getParentRoute: () => projectsRoute, + path: '$projectId', + ...createRouteLifecycleOptions('project'), + remountDeps: ({ params }) => ({ + projectId: params.projectId, + }), + component: ProjectLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.tsx new file mode 100644 index 0000000000..8bce25ff69 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.projects.tsx @@ -0,0 +1,23 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { createOutletsRemountsProjectsMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { orgRoute } from './workspace.$orgId' + +function ProjectsLayout() { + const params = projectsRoute.useParams() + const marker = () => createOutletsRemountsProjectsMarker(params()) + + return ( + + + + ) +} + +export const projectsRoute = createRoute({ + getParentRoute: () => orgRoute, + path: 'projects', + ...createRouteLifecycleOptions('projects'), + component: ProjectsLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.tsx new file mode 100644 index 0000000000..34f26f9f20 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.$orgId.tsx @@ -0,0 +1,28 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { createOutletsRemountsMarker } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { workspaceRoute } from './workspace' + +function OrgLayout() { + const params = orgRoute.useParams() + const marker = () => + createOutletsRemountsMarker({ + kind: 'org', + orgId: params().orgId, + }) + + return ( + +
+ + + ) +} + +export const orgRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: '$orgId', + ...createRouteLifecycleOptions('org'), + component: OrgLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.tsx new file mode 100644 index 0000000000..9ebe81c193 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/src/routes/workspace.tsx @@ -0,0 +1,21 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { outletsRemountsScenarioSlug } from '../../../shared' +import { createRouteLifecycleOptions } from '../outletsRemountsRuntime' +import { RouteShell } from '../routeShell' +import { rootRoute } from './__root' + +function WorkspaceLayout() { + return ( + 'workspace'}> +
+ + + ) +} + +export const workspaceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/workspace', + ...createRouteLifecycleOptions('workspace'), + component: WorkspaceLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/tsconfig.json b/benchmarks/client-nav/scenarios/outlets-remounts/solid/tsconfig.json new file mode 100644 index 0000000000..6639ffa0cc --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.tsx", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/solid/vite.config.ts b/benchmarks/client-nav/scenarios/outlets-remounts/solid/vite.config.ts new file mode 100644 index 0000000000..accad10974 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/solid/vite.config.ts @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav outlets-remounts (solid)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/project.json b/benchmarks/client-nav/scenarios/outlets-remounts/vue/project.json new file mode 100644 index 0000000000..7c4f0a2bdc --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-outlets-remounts-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/setup.ts b/benchmarks/client-nav/scenarios/outlets-remounts/vue/setup.ts new file mode 100644 index 0000000000..081cd3cbaf --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createOutletsRemountsWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const app = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createOutletsRemountsWorkload('vue', app) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/outlets-remounts/vue/speed.bench.ts new file mode 100644 index 0000000000..eb402bfd12 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav outlets-remounts', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/outlets-remounts/vue/speed.flame.ts new file mode 100644 index 0000000000..257b8d40a4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/speed.flame.ts @@ -0,0 +1,19 @@ +import { installOutletsRemountsFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installOutletsRemountsFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/app.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/app.tsx new file mode 100644 index 0000000000..055035d44f --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/app.tsx @@ -0,0 +1,31 @@ +import { RouterProvider } from '@tanstack/vue-router' +import { createApp } from 'vue' +import { getRouter } from './router' + +export { + getOutletsRemountsComponentCounters, + getOutletsRemountsLifecycleCounters, + resetOutletsRemountsCounters, +} from './outletsRemountsRuntime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/outletsRemountsRuntime.ts b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/outletsRemountsRuntime.ts new file mode 100644 index 0000000000..35221b53d3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/outletsRemountsRuntime.ts @@ -0,0 +1,11 @@ +import { createOutletsRemountsRuntime } from '../../shared' + +export const { + createRouteLifecycleOptions, + getOutletsRemountsComponentCounters, + getOutletsRemountsComponentRenderCount, + getOutletsRemountsLifecycleCounters, + recordComponentMount, + recordComponentRender, + resetOutletsRemountsCounters, +} = createOutletsRemountsRuntime() diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routeSection.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routeSection.tsx new file mode 100644 index 0000000000..0131ae098a --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routeSection.tsx @@ -0,0 +1,29 @@ +import * as Vue from 'vue' +import { + getOutletsRemountsComponentRenderCount, + recordComponentRender, +} from './outletsRemountsRuntime' +import type { OutletsRemountsComponentId } from '../../shared' + +export function createRouteSection( + routeId: OutletsRemountsComponentId, + marker: string, + mountIndex: number, + children: Vue.VNodeChild, +) { + const checksum = recordComponentRender(routeId, marker) + + return ( +
+ + {children} +
+ ) +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routeTree.tsx new file mode 100644 index 0000000000..9ca20bdbf3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routeTree.tsx @@ -0,0 +1,17 @@ +import { rootRoute } from './routes/__root' +import { workspaceRoute } from './routes/workspace' +import { orgRoute } from './routes/workspace.$orgId' +import { projectsRoute } from './routes/workspace.$orgId.projects' +import { projectRoute } from './routes/workspace.$orgId.projects.$projectId' +import { boardRoute } from './routes/workspace.$orgId.projects.$projectId.boards.$boardId' +import { cardRoute } from './routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId' + +export const routeTree = rootRoute.addChildren([ + workspaceRoute.addChildren([ + orgRoute.addChildren([ + projectsRoute.addChildren([ + projectRoute.addChildren([boardRoute.addChildren([cardRoute])]), + ]), + ]), + ]), +]) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/router.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/router.tsx new file mode 100644 index 0000000000..c60f93f77b --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { outletsRemountsInitialPath } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [outletsRemountsInitialPath], + }), + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..1904cf90ef --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/__root.tsx @@ -0,0 +1,12 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +const Root = Vue.defineComponent({ + setup() { + return () => + }, +}) + +export const rootRoute = createRootRoute({ + component: Root, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx new file mode 100644 index 0000000000..e05f35f8e4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.cards.$cardId.tsx @@ -0,0 +1,51 @@ +import * as Vue from 'vue' +import { createRoute, useParams } from '@tanstack/vue-router' +import { + createOutletsRemountsMarker, + readOutletsRemountsParam, +} from '../../../shared' +import { + createRouteLifecycleOptions, + recordComponentMount, +} from '../outletsRemountsRuntime' +import { createRouteSection } from '../routeSection' +import { boardRoute } from './workspace.$orgId.projects.$projectId.boards.$boardId' + +const CardPage = Vue.defineComponent({ + setup() { + const params = useParams({ strict: false }) + const getMarker = () => + createOutletsRemountsMarker({ + kind: 'card', + orgId: readOutletsRemountsParam(params.value, 'orgId'), + projectId: readOutletsRemountsParam(params.value, 'projectId'), + boardId: readOutletsRemountsParam(params.value, 'boardId'), + cardId: readOutletsRemountsParam(params.value, 'cardId'), + }) + const mountIndex = recordComponentMount('card', getMarker()) + + return () => { + const marker = getMarker() + + return createRouteSection( + 'card', + marker, + mountIndex, +
, + ) + } + }, +}) + +export const cardRoute = createRoute({ + getParentRoute: () => boardRoute, + path: 'cards/$cardId', + ...createRouteLifecycleOptions('card'), + component: CardPage, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx new file mode 100644 index 0000000000..5477ac56e6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.boards.$boardId.tsx @@ -0,0 +1,30 @@ +import * as Vue from 'vue' +import { Outlet, createRoute, useParams } from '@tanstack/vue-router' +import { createOutletsRemountsBoardMarker } from '../../../shared' +import { + createRouteLifecycleOptions, + recordComponentMount, +} from '../outletsRemountsRuntime' +import { createRouteSection } from '../routeSection' +import { projectRoute } from './workspace.$orgId.projects.$projectId' + +const BoardLayout = Vue.defineComponent({ + setup() { + const params = useParams({ strict: false }) + const getMarker = () => createOutletsRemountsBoardMarker(params.value) + const mountIndex = recordComponentMount('board', getMarker()) + + return () => + createRouteSection('board', getMarker(), mountIndex, ) + }, +}) + +export const boardRoute = createRoute({ + getParentRoute: () => projectRoute, + path: 'boards/$boardId', + ...createRouteLifecycleOptions('board'), + remountDeps: ({ params }) => ({ + boardId: params.boardId, + }), + component: BoardLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.tsx new file mode 100644 index 0000000000..3012c6458b --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.$projectId.tsx @@ -0,0 +1,30 @@ +import * as Vue from 'vue' +import { Outlet, createRoute, useParams } from '@tanstack/vue-router' +import { createOutletsRemountsProjectMarker } from '../../../shared' +import { + createRouteLifecycleOptions, + recordComponentMount, +} from '../outletsRemountsRuntime' +import { createRouteSection } from '../routeSection' +import { projectsRoute } from './workspace.$orgId.projects' + +const ProjectLayout = Vue.defineComponent({ + setup() { + const params = useParams({ strict: false }) + const getMarker = () => createOutletsRemountsProjectMarker(params.value) + const mountIndex = recordComponentMount('project', getMarker()) + + return () => + createRouteSection('project', getMarker(), mountIndex, ) + }, +}) + +export const projectRoute = createRoute({ + getParentRoute: () => projectsRoute, + path: '$projectId', + ...createRouteLifecycleOptions('project'), + remountDeps: ({ params }) => ({ + projectId: params.projectId, + }), + component: ProjectLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.tsx new file mode 100644 index 0000000000..8bf3e81bbe --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.projects.tsx @@ -0,0 +1,27 @@ +import * as Vue from 'vue' +import { Outlet, createRoute, useParams } from '@tanstack/vue-router' +import { createOutletsRemountsProjectsMarker } from '../../../shared' +import { + createRouteLifecycleOptions, + recordComponentMount, +} from '../outletsRemountsRuntime' +import { createRouteSection } from '../routeSection' +import { orgRoute } from './workspace.$orgId' + +const ProjectsLayout = Vue.defineComponent({ + setup() { + const params = useParams({ strict: false }) + const getMarker = () => createOutletsRemountsProjectsMarker(params.value) + const mountIndex = recordComponentMount('projects', getMarker()) + + return () => + createRouteSection('projects', getMarker(), mountIndex, ) + }, +}) + +export const projectsRoute = createRoute({ + getParentRoute: () => orgRoute, + path: 'projects', + ...createRouteLifecycleOptions('projects'), + component: ProjectsLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.tsx new file mode 100644 index 0000000000..ee87e14e8e --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.$orgId.tsx @@ -0,0 +1,48 @@ +import * as Vue from 'vue' +import { Outlet, createRoute, useParams } from '@tanstack/vue-router' +import { + createOutletsRemountsMarker, + readOutletsRemountsParam, +} from '../../../shared' +import { + createRouteLifecycleOptions, + recordComponentMount, +} from '../outletsRemountsRuntime' +import { createRouteSection } from '../routeSection' +import { workspaceRoute } from './workspace' + +const OrgLayout = Vue.defineComponent({ + setup() { + const params = useParams({ strict: false }) + const getMarker = () => + createOutletsRemountsMarker({ + kind: 'org', + orgId: readOutletsRemountsParam(params.value, 'orgId'), + }) + const mountIndex = recordComponentMount('org', getMarker()) + + return () => { + const marker = getMarker() + + return createRouteSection( + 'org', + marker, + mountIndex, + <> +
+ + , + ) + } + }, +}) + +export const orgRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: '$orgId', + ...createRouteLifecycleOptions('org'), + component: OrgLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.tsx b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.tsx new file mode 100644 index 0000000000..f11db4fe2f --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/src/routes/workspace.tsx @@ -0,0 +1,34 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { outletsRemountsScenarioSlug } from '../../../shared' +import { + createRouteLifecycleOptions, + recordComponentMount, +} from '../outletsRemountsRuntime' +import { createRouteSection } from '../routeSection' +import { rootRoute } from './__root' + +const WorkspaceLayout = Vue.defineComponent({ + setup() { + const marker = 'workspace' + const mountIndex = recordComponentMount('workspace', marker) + + return () => + createRouteSection( + 'workspace', + marker, + mountIndex, + <> +
+ + , + ) + }, +}) + +export const workspaceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/workspace', + ...createRouteLifecycleOptions('workspace'), + component: WorkspaceLayout, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/tsconfig.json b/benchmarks/client-nav/scenarios/outlets-remounts/vue/tsconfig.json new file mode 100644 index 0000000000..39eed5a9ca --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.tsx", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/vue/vite.config.ts b/benchmarks/client-nav/scenarios/outlets-remounts/vue/vite.config.ts new file mode 100644 index 0000000000..3370d561e6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/vue/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav outlets-remounts (vue)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/outlets-remounts/workload.ts b/benchmarks/client-nav/scenarios/outlets-remounts/workload.ts new file mode 100644 index 0000000000..8ebdab228a --- /dev/null +++ b/benchmarks/client-nav/scenarios/outlets-remounts/workload.ts @@ -0,0 +1,165 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import { + createOutletsRemountsLocations, + outletsRemountsInitialLocation, + type OutletsRemountsComponentCounters, + type OutletsRemountsLifecycleCounters, + type OutletsRemountsLocation, +} from './shared' + +export interface OutletsRemountsAppControls { + mountTestApp: MountTestApp + resetOutletsRemountsCounters: () => void + getOutletsRemountsComponentCounters: () => OutletsRemountsComponentCounters + getOutletsRemountsLifecycleCounters: () => OutletsRemountsLifecycleCounters +} + +function readActiveMarker(container: ParentNode) { + const card = container.querySelector( + '[data-outlets-card-marker]', + ) + + if (card) { + return { + kind: 'card', + marker: card.dataset.outletsCardMarker, + } + } + + const org = container.querySelector('[data-outlets-org-marker]') + + if (org) { + return { + kind: 'org', + marker: org.dataset.outletsOrgMarker, + } + } + + return undefined +} + +function assertCountersEqual( + routeId: string, + hook: string, + actual: number, + expected: number, +) { + if (actual !== expected) { + throw new Error( + `Expected ${routeId}.${hook} to equal ${expected}, got ${actual}`, + ) + } +} + +function assertLifecycleSanity(counters: OutletsRemountsLifecycleCounters) { + for (const routeId of ['workspace', 'org'] as const) { + assertCountersEqual(routeId, 'enter', counters[routeId].enter, 0) + assertCountersEqual(routeId, 'stay', counters[routeId].stay, 6) + assertCountersEqual(routeId, 'leave', counters[routeId].leave, 0) + } + + for (const routeId of ['projects', 'project', 'board', 'card'] as const) { + assertCountersEqual(routeId, 'enter', counters[routeId].enter, 1) + assertCountersEqual(routeId, 'stay', counters[routeId].stay, 4) + assertCountersEqual(routeId, 'leave', counters[routeId].leave, 1) + } +} + +function assertRemountSanity(counters: OutletsRemountsComponentCounters) { + assertCountersEqual('project', 'mounts', counters.project.mounts, 3) + assertCountersEqual('board', 'mounts', counters.board.mounts, 4) + + if (counters.project.renders < counters.project.mounts) { + throw new Error( + `Expected project renders to be at least project mounts, got ${counters.project.renders}/${counters.project.mounts}`, + ) + } + + if (counters.board.renders < counters.board.mounts) { + throw new Error( + `Expected board renders to be at least board mounts, got ${counters.board.renders}/${counters.board.mounts}`, + ) + } +} + +export function createOutletsRemountsWorkload( + framework: Framework, + app: OutletsRemountsAppControls, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp: app.mountTestApp }) + const locations = createOutletsRemountsLocations() + + async function waitForMarker(location: OutletsRemountsLocation) { + await lifecycle.waitForCounter( + () => { + const actual = readActiveMarker(lifecycle.getContainer()) + return actual?.kind === location.target.kind && + actual.marker === location.marker + ? 1 + : 0 + }, + 1, + { + label: `outlets marker ${location.marker}`, + }, + ) + } + + async function navigateTo(location: OutletsRemountsLocation) { + await lifecycle.navigate( + { + to: location.to, + params: location.params, + replace: true, + }, + { + label: `navigate ${location.marker}`, + wait: 'rendered', + }, + ) + + await waitForMarker(location) + } + + async function before() { + await lifecycle.before() + await waitForMarker(outletsRemountsInitialLocation) + app.resetOutletsRemountsCounters() + } + + async function run() { + for (const location of locations) { + await navigateTo(location) + } + } + + async function sanity() { + await before() + + try { + for (const location of locations.slice(0, 6)) { + await navigateTo(location) + } + + assertLifecycleSanity(app.getOutletsRemountsLifecycleCounters()) + assertRemountSanity(app.getOutletsRemountsComponentCounters()) + } finally { + await lifecycle.after() + } + } + + return { + name: `client outlets remounts loop (${framework})`, + before, + run, + sanity, + after: lifecycle.after, + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/project.json b/benchmarks/client-nav/scenarios/preloading/react/project.json new file mode 100644 index 0000000000..8b20cccd8c --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/project.json @@ -0,0 +1,53 @@ +{ + "name": "@benchmarks/client-nav-preloading-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts" + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/setup.ts b/benchmarks/client-nav/scenarios/preloading/react/setup.ts new file mode 100644 index 0000000000..19cdd15456 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/setup.ts @@ -0,0 +1,13 @@ +import type * as App from './src/app' +import { createPreloadingWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { getPreloadingCounters, mountTestApp, resetPreloadingCounters } = + (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createPreloadingWorkload( + 'react', + mountTestApp, + getPreloadingCounters, + resetPreloadingCounters, +) diff --git a/benchmarks/client-nav/scenarios/preloading/react/speed.bench.ts b/benchmarks/client-nav/scenarios/preloading/react/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/preloading/react/speed.flame.ts b/benchmarks/client-nav/scenarios/preloading/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/app.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/app.tsx new file mode 100644 index 0000000000..1e3d01c97a --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/app.tsx @@ -0,0 +1,25 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export { getPreloadingCounters, resetPreloadingCounters } from './preloading' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/preloading.ts b/benchmarks/client-nav/scenarios/preloading/react/src/preloading.ts new file mode 100644 index 0000000000..81c324ab19 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/preloading.ts @@ -0,0 +1,25 @@ +export { + BOOTSTRAP_INTENT_ITEM_ID, + BOOTSTRAP_RENDER_REPORT_ID, + BOOTSTRAP_VIEWPORT_ITEM_ID, + DEFAULT_ITEM_SEARCH, + DEFAULT_REPORT_SEARCH, + INTENT_ITEM_SEARCH, + VIEWPORT_ITEM_SEARCH, + getPreloadingCounters, + normalizeItemSearch, + normalizePreloadIndexSearch, + normalizeReportSearch, + preloadComponent, + preloadingInitialEntry, + recordDetailLoader, + recordItemBeforeLoad, + recordItemLoader, + recordLazyLoader, + recordLazyRouteResolution, + recordReportLoader, + reportPreloadStaleWindowMs, + resetPreloadingCounters, + runPreloadingComputation, + staleWindowMs, +} from '../../shared' diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/routeTree.tsx new file mode 100644 index 0000000000..8deda71072 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/routeTree.tsx @@ -0,0 +1,23 @@ +import { createRootRouteForPreloading } from './routes/__root' +import { createLazyPreloadRoute } from './routes/preload.lazy.$lazyId' +import { createPreloadIndexRoute } from './routes/preload' +import { createItemRoutes } from './routes/preload.items.$itemId' +import { createParkRoute } from './routes/preload.park' +import { createReportRoute } from './routes/preload.reports.$reportId' + +export function createRouteTree() { + const rootRoute = createRootRouteForPreloading() + const preloadIndexRoute = createPreloadIndexRoute(rootRoute) + const { itemRoute, itemDetailsRoute } = createItemRoutes(rootRoute) + const reportRoute = createReportRoute(rootRoute) + const parkRoute = createParkRoute(rootRoute) + const lazyRoute = createLazyPreloadRoute(rootRoute) + + return rootRoute.addChildren([ + preloadIndexRoute, + itemRoute.addChildren([itemDetailsRoute]), + reportRoute, + parkRoute, + lazyRoute, + ]) +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/router.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/router.tsx new file mode 100644 index 0000000000..5e25966c10 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/router.tsx @@ -0,0 +1,19 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { preloadingInitialEntry } from './preloading' +import { createRouteTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [preloadingInitialEntry], + }), + defaultPreloadDelay: 0, + routeTree: createRouteTree(), + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/routes/__root.tsx new file mode 100644 index 0000000000..24cb24bb16 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/routes/__root.tsx @@ -0,0 +1,11 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export function createRootRouteForPreloading() { + return createRootRoute({ + component: RootComponent, + }) +} + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.items.$itemId.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.items.$itemId.tsx new file mode 100644 index 0000000000..2f48215145 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.items.$itemId.tsx @@ -0,0 +1,79 @@ +import type { ReactElement } from 'react' +import { Outlet, createRoute } from '@tanstack/react-router' +import type { createRootRouteForPreloading } from './__root' +import { + normalizeItemSearch, + preloadComponent, + recordDetailLoader, + recordItemBeforeLoad, + recordItemLoader, + runPreloadingComputation, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => ReactElement) & { + preload?: () => Promise +} + +export function createItemRoutes(rootRoute: RootRoute) { + const itemRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/items/$itemId', + validateSearch: normalizeItemSearch, + beforeLoad: ({ params }) => ({ + itemSeed: recordItemBeforeLoad(params.itemId), + }), + loaderDeps: ({ search }) => ({ view: search.view }), + loader: ({ params, deps, context }) => ({ + checksum: recordItemLoader(params.itemId, deps), + contextChecksum: runPreloadingComputation(context.itemSeed, 12), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + component: ItemPage, + }) + + const itemDetailsRoute = createRoute({ + getParentRoute: () => itemRoute, + path: 'details', + loaderDeps: ({ search }) => ({ view: search.view }), + loader: ({ params, deps }) => ({ + checksum: recordDetailLoader(params.itemId, deps), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + component: DetailsPage, + }) + + function ItemPage() { + const params = itemRoute.useParams() + + return ( +
+ +
+ ) + } + + function DetailsPage() { + const params = itemDetailsRoute.useParams() + + return ( +
+ Details +
+ ) + } + + ;(ItemPage as PreloadableComponent).preload = () => preloadComponent('item') + ;(DetailsPage as PreloadableComponent).preload = () => + preloadComponent('details') + + return { itemRoute, itemDetailsRoute } +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.lazy.$lazyId.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.lazy.$lazyId.tsx new file mode 100644 index 0000000000..181ba15f6c --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.lazy.$lazyId.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from 'react' +import { createLazyRoute, createRoute } from '@tanstack/react-router' +import type { createRootRouteForPreloading } from './__root' +import { + preloadComponent, + recordLazyLoader, + recordLazyRouteResolution, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => ReactElement) & { + preload?: () => Promise +} + +export function createLazyPreloadRoute(rootRoute: RootRoute) { + const lazyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/lazy/$lazyId', + loader: ({ params }) => ({ + checksum: recordLazyLoader(params.lazyId), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + }).lazy(async () => { + recordLazyRouteResolution() + return createLazyRoute('/preload/lazy/$lazyId')({ + component: LazyPage, + }) + }) + + function LazyPage() { + const params = lazyRoute.useParams() + + return ( +
+ Lazy +
+ ) + } + + ;(LazyPage as PreloadableComponent).preload = () => preloadComponent('lazy') + + return lazyRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.park.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.park.tsx new file mode 100644 index 0000000000..5482d9a613 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.park.tsx @@ -0,0 +1,16 @@ +import { createRoute } from '@tanstack/react-router' +import type { createRootRouteForPreloading } from './__root' + +type RootRoute = ReturnType + +export function createParkRoute(rootRoute: RootRoute) { + return createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/park', + component: ParkPage, + }) +} + +function ParkPage() { + return
Park
+} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.reports.$reportId.tsx new file mode 100644 index 0000000000..8c19722dd6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.reports.$reportId.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from 'react' +import { createRoute } from '@tanstack/react-router' +import type { createRootRouteForPreloading } from './__root' +import { + normalizeReportSearch, + preloadComponent, + recordReportLoader, + reportPreloadStaleWindowMs, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => ReactElement) & { + preload?: () => Promise +} + +export function createReportRoute(rootRoute: RootRoute) { + const reportRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/reports/$reportId', + validateSearch: normalizeReportSearch, + loaderDeps: ({ search }) => ({ tab: search.tab, page: search.page }), + loader: ({ params, deps }) => ({ + checksum: recordReportLoader(params.reportId, deps), + }), + staleTime: staleWindowMs, + preloadStaleTime: reportPreloadStaleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: reportPreloadStaleWindowMs, + component: ReportPage, + }) + + function ReportPage() { + const params = reportRoute.useParams() + + return ( +
+ Report +
+ ) + } + + ;(ReportPage as PreloadableComponent).preload = () => + preloadComponent('report') + + return reportRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.tsx b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.tsx new file mode 100644 index 0000000000..04d37e496f --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/src/routes/preload.tsx @@ -0,0 +1,72 @@ +import { Link, createRoute } from '@tanstack/react-router' +import type { createRootRouteForPreloading } from './__root' +import { + BOOTSTRAP_INTENT_ITEM_ID, + DEFAULT_ITEM_SEARCH, + DEFAULT_REPORT_SEARCH, + INTENT_ITEM_SEARCH, + VIEWPORT_ITEM_SEARCH, + normalizePreloadIndexSearch, +} from '../preloading' + +type RootRoute = ReturnType + +export function createPreloadIndexRoute(rootRoute: RootRoute) { + const preloadIndexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload', + validateSearch: normalizePreloadIndexSearch, + component: PreloadIndex, + }) + + function PreloadIndex() { + const search = preloadIndexRoute.useSearch() + + return ( +
+ + Intent item + + + Render report + + + Viewport item + + + Manual item + +
+ ) + } + + return preloadIndexRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/tsconfig.json b/benchmarks/client-nav/scenarios/preloading/react/tsconfig.json new file mode 100644 index 0000000000..e5056ec745 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/preloading/react/vite.config.ts b/benchmarks/client-nav/scenarios/preloading/react/vite.config.ts new file mode 100644 index 0000000000..01c4d2f0ac --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/react/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav preloading (react)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/preloading/shared.ts b/benchmarks/client-nav/scenarios/preloading/shared.ts new file mode 100644 index 0000000000..4f25245285 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/shared.ts @@ -0,0 +1,571 @@ +import type { AnyRouter } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' +import { + createClientNavLifecycle, + warnClientNavDevMode, + type Framework, + type MountTestApp, +} from '#client-nav/lifecycle' + +export type PreloadComponentKind = 'item' | 'details' | 'report' | 'lazy' + +export interface ItemSearch { + view: string +} + +export interface ReportSearch { + tab: string + page: number +} + +export interface PreloadIndexSearch { + intentItemId: string + renderReportId: string + viewportItemId: string +} + +export interface PreloadingCounterSnapshot { + itemBeforeLoads: Record + itemLoaders: Record + detailLoaders: Record + reportLoaders: Record + lazyLoaders: Record + componentPreloads: Record + lazyRouteResolutions: number +} + +type PreloadingCounters = PreloadingCounterSnapshot + +type PreloadingTarget = { + manualItemId: string + manualItemSearch: ItemSearch + intentItemId: string + intentItemSearch: ItemSearch + renderReportId: string + viewportItemId: string + lazyId: string + changedReportId: string + changedReportSearch: ReportSearch +} + +type ItemPreloadOptions = { + to: '/preload/items/$itemId' + params: { itemId: string } + search: ItemSearch +} + +type DetailPreloadOptions = { + to: '/preload/items/$itemId/details' + params: { itemId: string } + search: ItemSearch +} + +type ReportPreloadOptions = { + to: '/preload/reports/$reportId' + params: { reportId: string } + search: ReportSearch +} + +type LazyPreloadOptions = { + to: '/preload/lazy/$lazyId' + params: { lazyId: string } +} + +type PreloadRouteOptions = + | ItemPreloadOptions + | DetailPreloadOptions + | ReportPreloadOptions + | LazyPreloadOptions + +type PreloadingRouter = AnyRouter & { + preloadRoute: (options: PreloadRouteOptions) => Promise +} + +export const BOOTSTRAP_RENDER_REPORT_ID = 'bootstrap-render-report' +export const BOOTSTRAP_INTENT_ITEM_ID = 'bootstrap-intent-item' +export const BOOTSTRAP_VIEWPORT_ITEM_ID = 'bootstrap-viewport-item' +export const DEFAULT_ITEM_SEARCH: ItemSearch = { view: 'summary' } +export const INTENT_ITEM_SEARCH: ItemSearch = { view: 'intent' } +export const VIEWPORT_ITEM_SEARCH: ItemSearch = { view: 'viewport' } +export const DEFAULT_REPORT_SEARCH: ReportSearch = { tab: 'summary', page: 1 } +export const staleWindowMs = 60_000 +export const reportPreloadStaleWindowMs = 120_000 +export const preloadingInitialEntry = [ + `/preload?intentItemId=${BOOTSTRAP_INTENT_ITEM_ID}`, + `renderReportId=${BOOTSTRAP_RENDER_REPORT_ID}`, + `viewportItemId=${BOOTSTRAP_VIEWPORT_ITEM_ID}`, +].join('&') + +const cycleCountPerInvocation = 2 +const benchmarkRandom = createDeterministicRandom(0x7072656c) +let benchmarkSequence = 0 + +let counters = createEmptyCounters() + +function createEmptyComponentCounters(): Record { + return { + item: 0, + details: 0, + report: 0, + lazy: 0, + } +} + +function createEmptyCounters(): PreloadingCounters { + return { + itemBeforeLoads: {}, + itemLoaders: {}, + detailLoaders: {}, + reportLoaders: {}, + lazyLoaders: {}, + componentPreloads: createEmptyComponentCounters(), + lazyRouteResolutions: 0, + } +} + +function increment(record: Record, key: string) { + record[key] = (record[key] ?? 0) + 1 +} + +function normalizeString(value: unknown, fallback: string) { + return typeof value === 'string' && value.length > 0 ? value : fallback +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const numberValue = Number(value) + return Number.isFinite(numberValue) && numberValue > 0 + ? Math.trunc(numberValue) + : fallback +} + +function nextTargetId(label: string) { + return `${label}-${benchmarkSequence.toString(36)}-${randomSegment(benchmarkRandom)}` +} + +function createPreloadingTarget(): PreloadingTarget { + const sequence = benchmarkSequence++ + + return { + manualItemId: nextTargetId('manual-item'), + manualItemSearch: { view: `manual-${sequence % 4}` }, + intentItemId: nextTargetId('intent-item'), + intentItemSearch: INTENT_ITEM_SEARCH, + renderReportId: nextTargetId('render-report'), + viewportItemId: nextTargetId('viewport-item'), + lazyId: nextTargetId('lazy-route'), + changedReportId: nextTargetId('changed-report'), + changedReportSearch: { + tab: `tab-${sequence % 5}`, + page: (sequence % 7) + 2, + }, + } +} + +export function itemLoaderKey(itemId: string, search: ItemSearch) { + return `${itemId}:${search.view}` +} + +export function reportLoaderKey(reportId: string, search: ReportSearch) { + return `${reportId}:${search.tab}:${search.page}` +} + +export function normalizeItemSearch( + search: Record, +): ItemSearch { + return { + view: normalizeString(search.view, DEFAULT_ITEM_SEARCH.view), + } +} + +export function normalizeReportSearch( + search: Record, +): ReportSearch { + return { + tab: normalizeString(search.tab, DEFAULT_REPORT_SEARCH.tab), + page: normalizePositiveInteger(search.page, DEFAULT_REPORT_SEARCH.page), + } +} + +export function normalizePreloadIndexSearch( + search: Record, +): PreloadIndexSearch { + return { + intentItemId: normalizeString( + search.intentItemId, + BOOTSTRAP_INTENT_ITEM_ID, + ), + renderReportId: normalizeString( + search.renderReportId, + BOOTSTRAP_RENDER_REPORT_ID, + ), + viewportItemId: normalizeString( + search.viewportItemId, + BOOTSTRAP_VIEWPORT_ITEM_ID, + ), + } +} + +export function runPreloadingComputation(seed: string | number, rounds = 28) { + const text = String(seed) + let value = typeof seed === 'number' ? Math.trunc(seed) : text.length + + for (let index = 0; index < rounds; index++) { + const charCode = text.charCodeAt(index % text.length) || 0 + value = (value * 1664525 + 1013904223 + charCode + index) >>> 0 + } + + return value +} + +export function resetPreloadingCounters() { + counters = createEmptyCounters() +} + +export function getPreloadingCounters(): PreloadingCounterSnapshot { + return { + itemBeforeLoads: { ...counters.itemBeforeLoads }, + itemLoaders: { ...counters.itemLoaders }, + detailLoaders: { ...counters.detailLoaders }, + reportLoaders: { ...counters.reportLoaders }, + lazyLoaders: { ...counters.lazyLoaders }, + componentPreloads: { ...counters.componentPreloads }, + lazyRouteResolutions: counters.lazyRouteResolutions, + } +} + +export function recordItemBeforeLoad(itemId: string) { + increment(counters.itemBeforeLoads, itemId) + return runPreloadingComputation(itemId, 18) +} + +export function recordItemLoader(itemId: string, search: ItemSearch) { + const key = itemLoaderKey(itemId, search) + increment(counters.itemLoaders, key) + return runPreloadingComputation(key) +} + +export function recordDetailLoader(itemId: string, search: ItemSearch) { + const key = itemLoaderKey(itemId, search) + increment(counters.detailLoaders, key) + return runPreloadingComputation(`details:${key}`) +} + +export function recordReportLoader(reportId: string, search: ReportSearch) { + const key = reportLoaderKey(reportId, search) + increment(counters.reportLoaders, key) + return runPreloadingComputation(`report:${key}`, 34) +} + +export function recordLazyLoader(lazyId: string) { + increment(counters.lazyLoaders, lazyId) + return runPreloadingComputation(`lazy:${lazyId}`, 32) +} + +export function recordComponentPreload(kind: PreloadComponentKind) { + counters.componentPreloads[kind] += 1 + return runPreloadingComputation(`component:${kind}`, 16) +} + +export function preloadComponent(kind: PreloadComponentKind) { + recordComponentPreload(kind) + return Promise.resolve() +} + +export function recordLazyRouteResolution() { + counters.lazyRouteResolutions += 1 + return runPreloadingComputation( + `lazy-route:${counters.lazyRouteResolutions}`, + 18, + ) +} + +export function createPreloadingWorkload( + framework: Framework, + mountTestApp: MountTestApp, + getCounters: () => PreloadingCounterSnapshot, + resetCounters: () => void, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp }) + + function getRouter() { + return lifecycle.getRouter() as unknown as PreloadingRouter + } + + function getItemLoaderCount(itemId: string, search: ItemSearch) { + return getCounters().itemLoaders[itemLoaderKey(itemId, search)] ?? 0 + } + + function getDetailLoaderCount(itemId: string, search: ItemSearch) { + return getCounters().detailLoaders[itemLoaderKey(itemId, search)] ?? 0 + } + + function getReportLoaderCount(reportId: string, search: ReportSearch) { + return getCounters().reportLoaders[reportLoaderKey(reportId, search)] ?? 0 + } + + function getLazyLoaderCount(lazyId: string) { + return getCounters().lazyLoaders[lazyId] ?? 0 + } + + function assertPageMarker(expected: string) { + const markers = Array.from( + lifecycle + .getContainer() + .querySelectorAll('[data-preloading-page]'), + ) + const marker = markers[markers.length - 1] + const actual = marker?.dataset.preloadingPage + + if (actual !== expected) { + throw new Error(`Expected preloading page ${expected}, got ${actual}`) + } + } + + async function preloadItem( + itemId: string, + search: ItemSearch, + label = 'preload item', + ) { + await lifecycle.waitForPromise( + getRouter().preloadRoute({ + to: '/preload/items/$itemId', + params: { itemId }, + search, + }), + { label }, + ) + } + + async function preloadReport( + reportId: string, + search: ReportSearch, + label = 'preload report', + ) { + await lifecycle.waitForPromise( + getRouter().preloadRoute({ + to: '/preload/reports/$reportId', + params: { reportId }, + search, + }), + { label }, + ) + } + + async function preloadLazy(lazyId: string, label = 'preload lazy route') { + await lifecycle.waitForPromise( + getRouter().preloadRoute({ + to: '/preload/lazy/$lazyId', + params: { lazyId }, + }), + { label }, + ) + } + + async function navigateToIndex(target: PreloadingTarget) { + const expectedReportCount = + getReportLoaderCount(target.renderReportId, DEFAULT_REPORT_SEARCH) + 1 + + await lifecycle.navigate( + { + to: '/preload', + search: { + intentItemId: target.intentItemId, + renderReportId: target.renderReportId, + viewportItemId: target.viewportItemId, + }, + replace: true, + }, + { label: 'navigate to preload index' }, + ) + assertPageMarker('index') + await lifecycle.waitForLink('intent-preload-item') + await lifecycle.waitForCounter( + () => getReportLoaderCount(target.renderReportId, DEFAULT_REPORT_SEARCH), + expectedReportCount, + { label: 'render-preload report loader' }, + ) + } + + async function dispatchIntentPreload(target: PreloadingTarget) { + const expectedItemCount = + getItemLoaderCount(target.intentItemId, target.intentItemSearch) + 1 + const link = await lifecycle.waitForLink('intent-preload-item') + + link.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: null, + }), + ) + + await lifecycle.waitForCounter( + () => getItemLoaderCount(target.intentItemId, target.intentItemSearch), + expectedItemCount, + { label: 'intent-preload item loader' }, + ) + } + + async function navigateToItem(itemId: string, search: ItemSearch) { + await lifecycle.navigate( + { + to: '/preload/items/$itemId', + params: { itemId }, + search, + replace: true, + }, + { label: 'navigate to preloaded item' }, + ) + assertPageMarker('item') + } + + async function navigateToDetails(itemId: string, search: ItemSearch) { + await lifecycle.navigate( + { + to: '/preload/items/$itemId/details', + params: { itemId }, + search, + replace: true, + }, + { label: 'navigate to preloaded details' }, + ) + assertPageMarker('details') + } + + async function runCycle(target: PreloadingTarget) { + await preloadItem( + target.manualItemId, + target.manualItemSearch, + 'manual item A', + ) + await navigateToIndex(target) + await preloadItem( + target.viewportItemId, + VIEWPORT_ITEM_SEARCH, + 'direct viewport item preload', + ) + await dispatchIntentPreload(target) + await preloadItem( + target.manualItemId, + target.manualItemSearch, + 'deduped item A', + ) + + const expectedLazyCount = getLazyLoaderCount(target.lazyId) + 1 + await preloadLazy(target.lazyId, 'warm lazy route') + await lifecycle.waitForCounter( + () => getLazyLoaderCount(target.lazyId), + expectedLazyCount, + { label: 'lazy route loader' }, + ) + await preloadLazy(target.lazyId, 'deduped lazy route') + + await navigateToItem(target.manualItemId, target.manualItemSearch) + await navigateToDetails(target.intentItemId, target.intentItemSearch) + await preloadReport( + target.changedReportId, + target.changedReportSearch, + 'changed report search deps', + ) + } + + async function before() { + resetCounters() + await lifecycle.before() + await lifecycle.waitForLink('intent-preload-item') + await lifecycle.waitForCounter( + () => + getReportLoaderCount(BOOTSTRAP_RENDER_REPORT_ID, DEFAULT_REPORT_SEARCH), + 1, + { label: 'bootstrap render preload' }, + ) + await lifecycle.navigate( + { + to: '/preload/park', + replace: true, + }, + { label: 'park after bootstrap render preload' }, + ) + assertPageMarker('park') + resetCounters() + } + + async function after() { + await lifecycle.after() + } + + async function run() { + for (let index = 0; index < cycleCountPerInvocation; index++) { + await runCycle(createPreloadingTarget()) + } + } + + async function sanity() { + await before() + + try { + const itemId = 'sanity-item' + const firstLoadCount = getItemLoaderCount(itemId, DEFAULT_ITEM_SEARCH) + await preloadItem(itemId, DEFAULT_ITEM_SEARCH, 'sanity initial preload') + + const afterPreloadCount = getItemLoaderCount(itemId, DEFAULT_ITEM_SEARCH) + if (afterPreloadCount !== firstLoadCount + 1) { + throw new Error( + `Expected sanity preload to run item loader once, got ${afterPreloadCount - firstLoadCount}`, + ) + } + + await preloadItem(itemId, DEFAULT_ITEM_SEARCH, 'sanity dedupe preload') + const afterDedupeCount = getItemLoaderCount(itemId, DEFAULT_ITEM_SEARCH) + if (afterDedupeCount !== afterPreloadCount) { + throw new Error('Expected repeated preload to dedupe item loader') + } + + await navigateToItem(itemId, DEFAULT_ITEM_SEARCH) + const afterNavigationCount = getItemLoaderCount( + itemId, + DEFAULT_ITEM_SEARCH, + ) + if (afterNavigationCount !== afterPreloadCount) { + throw new Error('Expected preloaded item data to promote on navigation') + } + + const detailId = 'sanity-detail-item' + await getRouter().preloadRoute({ + to: '/preload/items/$itemId/details', + params: { itemId: detailId }, + search: INTENT_ITEM_SEARCH, + }) + if (getDetailLoaderCount(detailId, INTENT_ITEM_SEARCH) !== 1) { + throw new Error('Expected detail preload to run detail loader once') + } + + const lazyId = 'sanity-lazy' + const lazyRouteResolutionsBefore = getCounters().lazyRouteResolutions + await preloadLazy(lazyId, 'sanity lazy preload') + await preloadLazy(lazyId, 'sanity lazy dedupe') + const lazyRouteResolutionsAfter = getCounters().lazyRouteResolutions + if (lazyRouteResolutionsAfter !== lazyRouteResolutionsBefore + 1) { + throw new Error( + 'Expected lazy route option merge to dedupe after warmup', + ) + } + } finally { + await after() + } + } + + return { + name: `client preloading loop (${framework})`, + before, + run, + sanity, + after, + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/project.json b/benchmarks/client-nav/scenarios/preloading/solid/project.json new file mode 100644 index 0000000000..156cd84f32 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/project.json @@ -0,0 +1,53 @@ +{ + "name": "@benchmarks/client-nav-preloading-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts" + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/setup.ts b/benchmarks/client-nav/scenarios/preloading/solid/setup.ts new file mode 100644 index 0000000000..8c82199f83 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/setup.ts @@ -0,0 +1,13 @@ +import type * as App from './src/app' +import { createPreloadingWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { getPreloadingCounters, mountTestApp, resetPreloadingCounters } = + (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createPreloadingWorkload( + 'solid', + mountTestApp, + getPreloadingCounters, + resetPreloadingCounters, +) diff --git a/benchmarks/client-nav/scenarios/preloading/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/preloading/solid/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/preloading/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/preloading/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/app.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/app.tsx new file mode 100644 index 0000000000..db0e2e05b2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/app.tsx @@ -0,0 +1,23 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { getRouter } from './router' + +export { getPreloadingCounters, resetPreloadingCounters } from './preloading' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/preloading.ts b/benchmarks/client-nav/scenarios/preloading/solid/src/preloading.ts new file mode 100644 index 0000000000..81c324ab19 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/preloading.ts @@ -0,0 +1,25 @@ +export { + BOOTSTRAP_INTENT_ITEM_ID, + BOOTSTRAP_RENDER_REPORT_ID, + BOOTSTRAP_VIEWPORT_ITEM_ID, + DEFAULT_ITEM_SEARCH, + DEFAULT_REPORT_SEARCH, + INTENT_ITEM_SEARCH, + VIEWPORT_ITEM_SEARCH, + getPreloadingCounters, + normalizeItemSearch, + normalizePreloadIndexSearch, + normalizeReportSearch, + preloadComponent, + preloadingInitialEntry, + recordDetailLoader, + recordItemBeforeLoad, + recordItemLoader, + recordLazyLoader, + recordLazyRouteResolution, + recordReportLoader, + reportPreloadStaleWindowMs, + resetPreloadingCounters, + runPreloadingComputation, + staleWindowMs, +} from '../../shared' diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/routeTree.tsx new file mode 100644 index 0000000000..8deda71072 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/routeTree.tsx @@ -0,0 +1,23 @@ +import { createRootRouteForPreloading } from './routes/__root' +import { createLazyPreloadRoute } from './routes/preload.lazy.$lazyId' +import { createPreloadIndexRoute } from './routes/preload' +import { createItemRoutes } from './routes/preload.items.$itemId' +import { createParkRoute } from './routes/preload.park' +import { createReportRoute } from './routes/preload.reports.$reportId' + +export function createRouteTree() { + const rootRoute = createRootRouteForPreloading() + const preloadIndexRoute = createPreloadIndexRoute(rootRoute) + const { itemRoute, itemDetailsRoute } = createItemRoutes(rootRoute) + const reportRoute = createReportRoute(rootRoute) + const parkRoute = createParkRoute(rootRoute) + const lazyRoute = createLazyPreloadRoute(rootRoute) + + return rootRoute.addChildren([ + preloadIndexRoute, + itemRoute.addChildren([itemDetailsRoute]), + reportRoute, + parkRoute, + lazyRoute, + ]) +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/router.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/router.tsx new file mode 100644 index 0000000000..96bd0e343a --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/router.tsx @@ -0,0 +1,19 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { preloadingInitialEntry } from './preloading' +import { createRouteTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [preloadingInitialEntry], + }), + defaultPreloadDelay: 0, + routeTree: createRouteTree(), + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..95b420e21e --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/__root.tsx @@ -0,0 +1,11 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export function createRootRouteForPreloading() { + return createRootRoute({ + component: RootComponent, + }) +} + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.items.$itemId.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.items.$itemId.tsx new file mode 100644 index 0000000000..df4819e1df --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.items.$itemId.tsx @@ -0,0 +1,79 @@ +import type { JSX } from 'solid-js' +import { Outlet, createRoute } from '@tanstack/solid-router' +import type { createRootRouteForPreloading } from './__root' +import { + normalizeItemSearch, + preloadComponent, + recordDetailLoader, + recordItemBeforeLoad, + recordItemLoader, + runPreloadingComputation, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => JSX.Element) & { + preload?: () => Promise +} + +export function createItemRoutes(rootRoute: RootRoute) { + const itemRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/items/$itemId', + validateSearch: normalizeItemSearch, + beforeLoad: ({ params }) => ({ + itemSeed: recordItemBeforeLoad(params.itemId), + }), + loaderDeps: ({ search }) => ({ view: search.view }), + loader: ({ params, deps, context }) => ({ + checksum: recordItemLoader(params.itemId, deps), + contextChecksum: runPreloadingComputation(context.itemSeed, 12), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + component: ItemPage, + }) + + const itemDetailsRoute = createRoute({ + getParentRoute: () => itemRoute, + path: 'details', + loaderDeps: ({ search }) => ({ view: search.view }), + loader: ({ params, deps }) => ({ + checksum: recordDetailLoader(params.itemId, deps), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + component: DetailsPage, + }) + + function ItemPage() { + const params = itemRoute.useParams() + + return ( +
+ +
+ ) + } + + function DetailsPage() { + const params = itemDetailsRoute.useParams() + + return ( +
+ Details +
+ ) + } + + ;(ItemPage as PreloadableComponent).preload = () => preloadComponent('item') + ;(DetailsPage as PreloadableComponent).preload = () => + preloadComponent('details') + + return { itemRoute, itemDetailsRoute } +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.lazy.$lazyId.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.lazy.$lazyId.tsx new file mode 100644 index 0000000000..0232259036 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.lazy.$lazyId.tsx @@ -0,0 +1,48 @@ +import type { JSX } from 'solid-js' +import { createLazyRoute, createRoute } from '@tanstack/solid-router' +import type { createRootRouteForPreloading } from './__root' +import { + preloadComponent, + recordLazyLoader, + recordLazyRouteResolution, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => JSX.Element) & { + preload?: () => Promise +} + +export function createLazyPreloadRoute(rootRoute: RootRoute) { + const lazyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/lazy/$lazyId', + loader: ({ params }) => ({ + checksum: recordLazyLoader(params.lazyId), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + }).lazy(async () => { + recordLazyRouteResolution() + return createLazyRoute('/preload/lazy/$lazyId')({ + component: LazyPage, + }) + }) + + function LazyPage() { + const params = lazyRoute.useParams() + + return ( +
+ Lazy +
+ ) + } + + ;(LazyPage as PreloadableComponent).preload = () => preloadComponent('lazy') + + return lazyRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.park.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.park.tsx new file mode 100644 index 0000000000..f77cdebf87 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.park.tsx @@ -0,0 +1,16 @@ +import { createRoute } from '@tanstack/solid-router' +import type { createRootRouteForPreloading } from './__root' + +type RootRoute = ReturnType + +export function createParkRoute(rootRoute: RootRoute) { + return createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/park', + component: ParkPage, + }) +} + +function ParkPage() { + return
Park
+} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.reports.$reportId.tsx new file mode 100644 index 0000000000..d802a8b7e8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.reports.$reportId.tsx @@ -0,0 +1,48 @@ +import type { JSX } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import type { createRootRouteForPreloading } from './__root' +import { + normalizeReportSearch, + preloadComponent, + recordReportLoader, + reportPreloadStaleWindowMs, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => JSX.Element) & { + preload?: () => Promise +} + +export function createReportRoute(rootRoute: RootRoute) { + const reportRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/reports/$reportId', + validateSearch: normalizeReportSearch, + loaderDeps: ({ search }) => ({ tab: search.tab, page: search.page }), + loader: ({ params, deps }) => ({ + checksum: recordReportLoader(params.reportId, deps), + }), + staleTime: staleWindowMs, + preloadStaleTime: reportPreloadStaleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: reportPreloadStaleWindowMs, + component: ReportPage, + }) + + function ReportPage() { + const params = reportRoute.useParams() + + return ( +
+ Report +
+ ) + } + + ;(ReportPage as PreloadableComponent).preload = () => + preloadComponent('report') + + return reportRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.tsx b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.tsx new file mode 100644 index 0000000000..d0d5ce2668 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/src/routes/preload.tsx @@ -0,0 +1,72 @@ +import { Link, createRoute } from '@tanstack/solid-router' +import type { createRootRouteForPreloading } from './__root' +import { + BOOTSTRAP_INTENT_ITEM_ID, + DEFAULT_ITEM_SEARCH, + DEFAULT_REPORT_SEARCH, + INTENT_ITEM_SEARCH, + VIEWPORT_ITEM_SEARCH, + normalizePreloadIndexSearch, +} from '../preloading' + +type RootRoute = ReturnType + +export function createPreloadIndexRoute(rootRoute: RootRoute) { + const preloadIndexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload', + validateSearch: normalizePreloadIndexSearch, + component: PreloadIndex, + }) + + function PreloadIndex() { + const search = preloadIndexRoute.useSearch() + + return ( +
+ + Intent item + + + Render report + + + Viewport item + + + Manual item + +
+ ) + } + + return preloadIndexRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/tsconfig.json b/benchmarks/client-nav/scenarios/preloading/solid/tsconfig.json new file mode 100644 index 0000000000..b549cd9fe8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/preloading/solid/vite.config.ts b/benchmarks/client-nav/scenarios/preloading/solid/vite.config.ts new file mode 100644 index 0000000000..041bd2e97b --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/solid/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav preloading (solid)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/preloading/vue/project.json b/benchmarks/client-nav/scenarios/preloading/vue/project.json new file mode 100644 index 0000000000..99abcade03 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/project.json @@ -0,0 +1,53 @@ +{ + "name": "@benchmarks/client-nav-preloading-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts" + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/setup.ts b/benchmarks/client-nav/scenarios/preloading/vue/setup.ts new file mode 100644 index 0000000000..692edcf7c6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/setup.ts @@ -0,0 +1,13 @@ +import type * as App from './src/app' +import { createPreloadingWorkload } from '../shared.ts' + +const appModulePath = './dist/app.js' +const { getPreloadingCounters, mountTestApp, resetPreloadingCounters } = + (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createPreloadingWorkload( + 'vue', + mountTestApp, + getPreloadingCounters, + resetPreloadingCounters, +) diff --git a/benchmarks/client-nav/scenarios/preloading/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/preloading/vue/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/preloading/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/preloading/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/app.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/app.tsx new file mode 100644 index 0000000000..53bfe2a4c8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/app.tsx @@ -0,0 +1,29 @@ +import { createApp } from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { getRouter } from './router' + +export { getPreloadingCounters, resetPreloadingCounters } from './preloading' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = createApp({ + setup() { + return () => + }, + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/preloading.ts b/benchmarks/client-nav/scenarios/preloading/vue/src/preloading.ts new file mode 100644 index 0000000000..81c324ab19 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/preloading.ts @@ -0,0 +1,25 @@ +export { + BOOTSTRAP_INTENT_ITEM_ID, + BOOTSTRAP_RENDER_REPORT_ID, + BOOTSTRAP_VIEWPORT_ITEM_ID, + DEFAULT_ITEM_SEARCH, + DEFAULT_REPORT_SEARCH, + INTENT_ITEM_SEARCH, + VIEWPORT_ITEM_SEARCH, + getPreloadingCounters, + normalizeItemSearch, + normalizePreloadIndexSearch, + normalizeReportSearch, + preloadComponent, + preloadingInitialEntry, + recordDetailLoader, + recordItemBeforeLoad, + recordItemLoader, + recordLazyLoader, + recordLazyRouteResolution, + recordReportLoader, + reportPreloadStaleWindowMs, + resetPreloadingCounters, + runPreloadingComputation, + staleWindowMs, +} from '../../shared' diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/routeTree.tsx new file mode 100644 index 0000000000..8deda71072 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/routeTree.tsx @@ -0,0 +1,23 @@ +import { createRootRouteForPreloading } from './routes/__root' +import { createLazyPreloadRoute } from './routes/preload.lazy.$lazyId' +import { createPreloadIndexRoute } from './routes/preload' +import { createItemRoutes } from './routes/preload.items.$itemId' +import { createParkRoute } from './routes/preload.park' +import { createReportRoute } from './routes/preload.reports.$reportId' + +export function createRouteTree() { + const rootRoute = createRootRouteForPreloading() + const preloadIndexRoute = createPreloadIndexRoute(rootRoute) + const { itemRoute, itemDetailsRoute } = createItemRoutes(rootRoute) + const reportRoute = createReportRoute(rootRoute) + const parkRoute = createParkRoute(rootRoute) + const lazyRoute = createLazyPreloadRoute(rootRoute) + + return rootRoute.addChildren([ + preloadIndexRoute, + itemRoute.addChildren([itemDetailsRoute]), + reportRoute, + parkRoute, + lazyRoute, + ]) +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/router.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/router.tsx new file mode 100644 index 0000000000..d68c3fb468 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/router.tsx @@ -0,0 +1,19 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { preloadingInitialEntry } from './preloading' +import { createRouteTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [preloadingInitialEntry], + }), + defaultPreloadDelay: 0, + routeTree: createRouteTree(), + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..47f2cc113d --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/__root.tsx @@ -0,0 +1,11 @@ +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +export function createRootRouteForPreloading() { + return createRootRoute({ + component: RootComponent, + }) +} + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.items.$itemId.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.items.$itemId.tsx new file mode 100644 index 0000000000..14ac3e2ddc --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.items.$itemId.tsx @@ -0,0 +1,82 @@ +import type { VNodeChild } from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import type { createRootRouteForPreloading } from './__root' +import { + normalizeItemSearch, + preloadComponent, + recordDetailLoader, + recordItemBeforeLoad, + recordItemLoader, + runPreloadingComputation, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => VNodeChild) & { + preload?: () => Promise +} + +export function createItemRoutes(rootRoute: RootRoute) { + const itemRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/items/$itemId', + validateSearch: normalizeItemSearch, + beforeLoad: ({ params }) => ({ + itemSeed: recordItemBeforeLoad(params.itemId), + }), + loaderDeps: ({ search }) => ({ view: search.view }), + loader: ({ params, deps, context }) => ({ + checksum: recordItemLoader(params.itemId, deps), + contextChecksum: runPreloadingComputation(context.itemSeed, 12), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + component: ItemPage, + }) + + const itemDetailsRoute = createRoute({ + getParentRoute: () => itemRoute, + path: 'details', + loaderDeps: ({ search }) => ({ view: search.view }), + loader: ({ params, deps }) => ({ + checksum: recordDetailLoader(params.itemId, deps), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + component: DetailsPage, + }) + + function ItemPage() { + const params = itemRoute.useParams() + + return ( +
+ +
+ ) + } + + function DetailsPage() { + const params = itemDetailsRoute.useParams() + + return ( +
+ Details +
+ ) + } + + ;(ItemPage as PreloadableComponent).preload = () => preloadComponent('item') + ;(DetailsPage as PreloadableComponent).preload = () => + preloadComponent('details') + + return { itemRoute, itemDetailsRoute } +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.lazy.$lazyId.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.lazy.$lazyId.tsx new file mode 100644 index 0000000000..9cf8bef5fa --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.lazy.$lazyId.tsx @@ -0,0 +1,48 @@ +import type { VNodeChild } from 'vue' +import { createLazyRoute, createRoute } from '@tanstack/vue-router' +import type { createRootRouteForPreloading } from './__root' +import { + preloadComponent, + recordLazyLoader, + recordLazyRouteResolution, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => VNodeChild) & { + preload?: () => Promise +} + +export function createLazyPreloadRoute(rootRoute: RootRoute) { + const lazyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/lazy/$lazyId', + loader: ({ params }) => ({ + checksum: recordLazyLoader(params.lazyId), + }), + staleTime: staleWindowMs, + preloadStaleTime: staleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: staleWindowMs, + }).lazy(async () => { + recordLazyRouteResolution() + return createLazyRoute('/preload/lazy/$lazyId')({ + component: LazyPage, + }) + }) + + function LazyPage() { + const params = lazyRoute.useParams() + + return ( +
+ Lazy +
+ ) + } + + ;(LazyPage as PreloadableComponent).preload = () => preloadComponent('lazy') + + return lazyRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.park.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.park.tsx new file mode 100644 index 0000000000..d351c95528 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.park.tsx @@ -0,0 +1,16 @@ +import { createRoute } from '@tanstack/vue-router' +import type { createRootRouteForPreloading } from './__root' + +type RootRoute = ReturnType + +export function createParkRoute(rootRoute: RootRoute) { + return createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/park', + component: ParkPage, + }) +} + +function ParkPage() { + return
Park
+} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.reports.$reportId.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.reports.$reportId.tsx new file mode 100644 index 0000000000..de86b0a679 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.reports.$reportId.tsx @@ -0,0 +1,51 @@ +import type { VNodeChild } from 'vue' +import { createRoute } from '@tanstack/vue-router' +import type { createRootRouteForPreloading } from './__root' +import { + normalizeReportSearch, + preloadComponent, + recordReportLoader, + reportPreloadStaleWindowMs, + staleWindowMs, +} from '../preloading' + +type RootRoute = ReturnType + +type PreloadableComponent = (() => VNodeChild) & { + preload?: () => Promise +} + +export function createReportRoute(rootRoute: RootRoute) { + const reportRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload/reports/$reportId', + validateSearch: normalizeReportSearch, + loaderDeps: ({ search }) => ({ tab: search.tab, page: search.page }), + loader: ({ params, deps }) => ({ + checksum: recordReportLoader(params.reportId, deps), + }), + staleTime: staleWindowMs, + preloadStaleTime: reportPreloadStaleWindowMs, + gcTime: staleWindowMs, + preloadGcTime: reportPreloadStaleWindowMs, + component: ReportPage, + }) + + function ReportPage() { + const params = reportRoute.useParams() + + return ( +
+ Report +
+ ) + } + + ;(ReportPage as PreloadableComponent).preload = () => + preloadComponent('report') + + return reportRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.tsx b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.tsx new file mode 100644 index 0000000000..306ac86a3a --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/src/routes/preload.tsx @@ -0,0 +1,72 @@ +import { Link, createRoute } from '@tanstack/vue-router' +import type { createRootRouteForPreloading } from './__root' +import { + BOOTSTRAP_INTENT_ITEM_ID, + DEFAULT_ITEM_SEARCH, + DEFAULT_REPORT_SEARCH, + INTENT_ITEM_SEARCH, + VIEWPORT_ITEM_SEARCH, + normalizePreloadIndexSearch, +} from '../preloading' + +type RootRoute = ReturnType + +export function createPreloadIndexRoute(rootRoute: RootRoute) { + const preloadIndexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preload', + validateSearch: normalizePreloadIndexSearch, + component: PreloadIndex, + }) + + function PreloadIndex() { + const search = preloadIndexRoute.useSearch() + + return ( +
+ + Intent item + + + Render report + + + Viewport item + + + Manual item + +
+ ) + } + + return preloadIndexRoute +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/tsconfig.json b/benchmarks/client-nav/scenarios/preloading/vue/tsconfig.json new file mode 100644 index 0000000000..24bdb3e3cb --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "../shared.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/preloading/vue/vite.config.ts b/benchmarks/client-nav/scenarios/preloading/vue/vite.config.ts new file mode 100644 index 0000000000..e78b75ba59 --- /dev/null +++ b/benchmarks/client-nav/scenarios/preloading/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav preloading (vue)', + watch: false, + environment: 'jsdom', + setupFiles: ['../../../vitest.setup.ts'], + }, +}) diff --git a/benchmarks/client-nav/scenarios/route-matching/flame-jsdom.ts b/benchmarks/client-nav/scenarios/route-matching/flame-jsdom.ts new file mode 100644 index 0000000000..3223ac0505 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/flame-jsdom.ts @@ -0,0 +1,27 @@ +import { window } from '../../jsdom.ts' + +export function installRouteMatchingFlameGlobals() { + const hadScrollTo = 'scrollTo' in globalThis + const previousScrollTo = globalThis.scrollTo + + Object.defineProperty(globalThis, 'scrollTo', { + configurable: true, + value: window.scrollTo.bind(window), + writable: true, + }) + + return () => { + if (hadScrollTo) { + Object.defineProperty(globalThis, 'scrollTo', { + configurable: true, + value: previousScrollTo, + writable: true, + }) + return + } + + Reflect.deleteProperty(globalThis, 'scrollTo') + } +} + +export { window } diff --git a/benchmarks/client-nav/scenarios/route-matching/react/project.json b/benchmarks/client-nav/scenarios/route-matching/react/project.json new file mode 100644 index 0000000000..b5c15ac35c --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-route-matching-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/react/setup.ts b/benchmarks/client-nav/scenarios/route-matching/react/setup.ts new file mode 100644 index 0000000000..9648a4e09e --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createRouteMatchingWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createRouteMatchingWorkload('react', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/route-matching/react/speed.bench.ts b/benchmarks/client-nav/scenarios/route-matching/react/speed.bench.ts new file mode 100644 index 0000000000..ebdf93f714 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav route-matching', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/route-matching/react/speed.flame.ts b/benchmarks/client-nav/scenarios/route-matching/react/speed.flame.ts new file mode 100644 index 0000000000..8ca05323af --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/speed.flame.ts @@ -0,0 +1,19 @@ +import { installRouteMatchingFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installRouteMatchingFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/route-matching/react/src/app.tsx b/benchmarks/client-nav/scenarios/route-matching/react/src/app.tsx new file mode 100644 index 0000000000..538a2c129e --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/src/app.tsx @@ -0,0 +1,23 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/react/src/route-components.tsx b/benchmarks/client-nav/scenarios/route-matching/react/src/route-components.tsx new file mode 100644 index 0000000000..f90de6c3d6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/src/route-components.tsx @@ -0,0 +1,21 @@ +import { createRouteMarker, type RouteKind } from '../../shared' + +type MarkerProps = { + kind: RouteKind + marker: string +} + +function RouteMarker(props: MarkerProps) { + return
+} + +export function createMarkerComponent(kind: RouteKind, marker: string) { + return function BenchRouteMarker() { + return + } +} + +export function RootNotFoundComponent() { + const marker = createRouteMarker('not-found', 'not-found') + return +} diff --git a/benchmarks/client-nav/scenarios/route-matching/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/route-matching/react/src/routeTree.tsx new file mode 100644 index 0000000000..6e43ab7141 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/src/routeTree.tsx @@ -0,0 +1,193 @@ +import { createRoute } from '@tanstack/react-router' +import { + DYNAMIC_ROUTE_COUNT, + OPTIONAL_ROUTE_COUNT, + PATHLESS_CHAIN_COUNT, + SPLAT_ROUTE_COUNT, + STATIC_ROUTE_COUNT, + createRouteMarker, +} from '../../shared' +import { + parseCatalogParams, + parseCodeParams, + parseDocsParams, + parsePriorityFallbackParams, + parsePriorityValueParams, + parseSlugParams, + stringifyCatalogParams, + stringifyCodeParams, + stringifyDocsParams, + stringifyPriorityFallbackParams, + stringifyPriorityValueParams, + stringifySlugParams, +} from '../../route-params' +import { createMarkerComponent } from './route-components' +import { Route as rootRoute } from './routes/__root' + +const staticRoutes = Array.from({ length: STATIC_ROUTE_COUNT }, (_, index) => { + const marker = createRouteMarker('static', `static-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/catalog/products/static-${index}`, + component: createMarkerComponent(marker.kind, marker.value), + }) +}) + +const dynamicRoutes = Array.from( + { length: DYNAMIC_ROUTE_COUNT }, + (_, index) => { + const marker = createRouteMarker('dynamic', `dynamic-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/catalog/products/dyn-${index}/$category/$id`, + params: { + parse: parseCatalogParams, + stringify: stringifyCatalogParams, + }, + component: createMarkerComponent(marker.kind, marker.value), + }) + }, +) + +const optionalRoutes = Array.from( + { length: OPTIONAL_ROUTE_COUNT }, + (_, index) => { + const marker = createRouteMarker('optional', `optional-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/docs/topic-${index}/{-$section}/$slug`, + params: { + parse: parseDocsParams, + stringify: stringifyDocsParams, + }, + component: createMarkerComponent(marker.kind, marker.value), + }) + }, +) + +const splatRoutes = Array.from({ length: SPLAT_ROUTE_COUNT }, (_, index) => { + const marker = createRouteMarker('splat', `splat-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/files/bucket-${index}/$`, + component: createMarkerComponent(marker.kind, marker.value), + }) +}) + +const priorityPrimaryMarker = createRouteMarker('priority-primary', 'primary') +const priorityPrimaryRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/priority/$value', + params: { + parse: parsePriorityValueParams, + priority: 20, + stringify: stringifyPriorityValueParams, + }, + component: createMarkerComponent( + priorityPrimaryMarker.kind, + priorityPrimaryMarker.value, + ), +}) + +const priorityFallbackMarker = createRouteMarker( + 'priority-fallback', + 'fallback', +) +const priorityFallbackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/priority/$fallback', + params: { + parse: parsePriorityFallbackParams, + priority: 1, + stringify: stringifyPriorityFallbackParams, + }, + component: createMarkerComponent( + priorityFallbackMarker.kind, + priorityFallbackMarker.value, + ), +}) + +const caseSensitiveMarker = createRouteMarker( + 'case-sensitive', + 'case-sensitive', +) +const caseSensitiveRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/case/Sensitive/$code', + caseSensitive: true, + params: { + parse: parseCodeParams, + stringify: stringifyCodeParams, + }, + component: createMarkerComponent( + caseSensitiveMarker.kind, + caseSensitiveMarker.value, + ), +}) + +const reservedParamMarker = createRouteMarker( + 'reserved-param', + 'reserved-param', +) +const reservedParamRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/reserved/$slug', + params: { + parse: parseSlugParams, + stringify: stringifySlugParams, + }, + component: createMarkerComponent( + reservedParamMarker.kind, + reservedParamMarker.value, + ), +}) + +function createPathlessChain(index: number) { + const first = createRoute({ + getParentRoute: () => rootRoute, + id: `pathless-${index}-0`, + }) + const second = createRoute({ + getParentRoute: () => first, + id: `pathless-${index}-1`, + }) + const third = createRoute({ + getParentRoute: () => second, + id: `pathless-${index}-2`, + }) + const fourth = createRoute({ + getParentRoute: () => third, + id: `pathless-${index}-3`, + }) + const marker = createRouteMarker('pathless', `pathless-${index}`) + const leaf = createRoute({ + getParentRoute: () => fourth, + path: `/pathless/chain-${index}/leaf`, + component: createMarkerComponent(marker.kind, marker.value), + }) + + return first.addChildren([ + second.addChildren([third.addChildren([fourth.addChildren([leaf])])]), + ]) +} + +const pathlessChains = Array.from( + { length: PATHLESS_CHAIN_COUNT }, + (_, index) => createPathlessChain(index), +) + +export const routeTree = rootRoute.addChildren([ + ...staticRoutes, + ...dynamicRoutes, + ...optionalRoutes, + ...splatRoutes, + priorityPrimaryRoute, + priorityFallbackRoute, + caseSensitiveRoute, + reservedParamRoute, + ...pathlessChains, +]) diff --git a/benchmarks/client-nav/scenarios/route-matching/react/src/router.tsx b/benchmarks/client-nav/scenarios/route-matching/react/src/router.tsx new file mode 100644 index 0000000000..01ab0487dc --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/src/router.tsx @@ -0,0 +1,19 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { INITIAL_ROUTE_PATH } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [INITIAL_ROUTE_PATH], + }), + pathParamsAllowedCharacters: ['@', ':'], + routeTree: routeTree as any, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/route-matching/react/src/routes/__root.tsx new file mode 100644 index 0000000000..0f1ca382f7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/src/routes/__root.tsx @@ -0,0 +1,11 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' +import { RootNotFoundComponent } from '../route-components' + +export const Route = createRootRoute({ + component: RootComponent, + notFoundComponent: RootNotFoundComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/route-matching/react/tsconfig.json b/benchmarks/client-nav/scenarios/route-matching/react/tsconfig.json new file mode 100644 index 0000000000..f2e7c1160a --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/route-matching/react/vite.config.ts b/benchmarks/client-nav/scenarios/route-matching/react/vite.config.ts new file mode 100644 index 0000000000..bdf55b4fa0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/react/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav route-matching (react)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/route-matching/route-params.ts b/benchmarks/client-nav/scenarios/route-matching/route-params.ts new file mode 100644 index 0000000000..97a05bde38 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/route-params.ts @@ -0,0 +1,113 @@ +import { normalizeNumericId, normalizeSlug } from './shared' + +type CatalogParams = { + category: string + id: number +} + +type DocsParams = { + section?: string + slug: string +} + +type SlugParams = { + slug: string +} + +type CodeParams = { + code: string +} + +type PriorityValueParams = { + value: string +} + +type PriorityFallbackParams = { + fallback: string +} + +export function parseCatalogParams(params: { category: string; id: string }) { + return { + category: normalizeSlug(params.category), + id: normalizeNumericId(params.id), + } +} + +export function stringifyCatalogParams(params: CatalogParams) { + return { + category: normalizeSlug(params.category), + id: `${params.id}`, + } +} + +export function parseDocsParams(params: { section?: string; slug: string }) { + return { + section: + params.section === undefined ? undefined : normalizeSlug(params.section), + slug: normalizeSlug(params.slug), + } +} + +export function stringifyDocsParams(params: DocsParams) { + return { + section: + params.section === undefined ? undefined : normalizeSlug(params.section), + slug: normalizeSlug(params.slug), + } +} + +export function parseSlugParams(params: { slug: string }) { + return { + slug: normalizeSlug(params.slug), + } +} + +export function stringifySlugParams(params: SlugParams) { + return { + slug: normalizeSlug(params.slug), + } +} + +export function parseCodeParams(params: { code: string }) { + return { + code: normalizeSlug(params.code), + } +} + +export function stringifyCodeParams(params: CodeParams) { + return { + code: normalizeSlug(params.code), + } +} + +export function parsePriorityValueParams(params: { value: string }) { + const value = normalizeSlug(params.value) + + if (!value.startsWith('fast-')) { + return false + } + + return { + value, + } +} + +export function stringifyPriorityValueParams(params: PriorityValueParams) { + return { + value: normalizeSlug(params.value), + } +} + +export function parsePriorityFallbackParams(params: { fallback: string }) { + return { + fallback: normalizeSlug(params.fallback), + } +} + +export function stringifyPriorityFallbackParams( + params: PriorityFallbackParams, +) { + return { + fallback: normalizeSlug(params.fallback), + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/shared.ts b/benchmarks/client-nav/scenarios/route-matching/shared.ts new file mode 100644 index 0000000000..641114a1fc --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/shared.ts @@ -0,0 +1,183 @@ +import { + createDeterministicRandom, + randomSegment, +} from '#client-nav/bench-utils' + +export const STATIC_ROUTE_COUNT = 80 +export const DYNAMIC_ROUTE_COUNT = 40 +export const OPTIONAL_ROUTE_COUNT = 24 +export const SPLAT_ROUTE_COUNT = 8 +export const PATHLESS_CHAIN_COUNT = 4 +export const PATHLESS_CHAIN_DEPTH = 4 +export const ROUTE_MATCHING_CYCLE_COUNT = 4 +export const ROUTE_MATCHING_NAVIGATION_COUNT = ROUTE_MATCHING_CYCLE_COUNT * 10 + +export const ROUTE_MATCHING_ROUTE_COUNTS = { + static: STATIC_ROUTE_COUNT, + dynamic: DYNAMIC_ROUTE_COUNT, + optional: OPTIONAL_ROUTE_COUNT, + splat: SPLAT_ROUTE_COUNT, + pathlessChains: PATHLESS_CHAIN_COUNT, + pathlessChainDepth: PATHLESS_CHAIN_DEPTH, +} as const + +export const INITIAL_ROUTE_PATH = '/catalog/products/static-0' + +export type RouteKind = + | 'static' + | 'dynamic' + | 'optional' + | 'splat' + | 'priority-primary' + | 'priority-fallback' + | 'case-sensitive' + | 'reserved-param' + | 'pathless' + | 'not-found' + +export interface RouteMarker { + kind: RouteKind + value: string +} + +export interface RouteMatchingLocation { + to: string + params?: Record + expected: RouteMarker + hrefIncludes?: string + wait?: 'rendered' | 'resolved' | 'idle' | 'none' +} + +export function createRouteMarker(kind: RouteKind, value: string): RouteMarker { + return { + kind, + value: `${kind}:${value}`, + } +} + +export const INITIAL_ROUTE_MARKER = createRouteMarker('static', 'static-0') + +export function normalizeNumericId(value: unknown) { + const parsed = Number(value) + + if (!Number.isFinite(parsed) || parsed < 0) { + return 0 + } + + return Math.trunc(parsed) +} + +export function normalizeSlug(value: unknown) { + const text = typeof value === 'string' ? value : '' + const normalized = text + .trim() + .toLowerCase() + .replace(/[^a-z0-9@:+-]+/g, '-') + .replace(/^-+|-+$/g, '') + + return normalized || 'empty' +} + +function pickIndex(random: () => number, count: number) { + return Math.floor(random() * count) +} + +export function createRouteMatchingLocations() { + const random = createDeterministicRandom(0x5eed_0202) + const locations: Array = [] + + for (let cycle = 0; cycle < ROUTE_MATCHING_CYCLE_COUNT; cycle++) { + const staticIndex = 1 + pickIndex(random, STATIC_ROUTE_COUNT - 1) + const dynamicIndex = pickIndex(random, DYNAMIC_ROUTE_COUNT) + const optionalWithIndex = pickIndex(random, OPTIONAL_ROUTE_COUNT) + const optionalWithoutIndex = (optionalWithIndex + 7) % OPTIONAL_ROUTE_COUNT + const splatIndex = pickIndex(random, SPLAT_ROUTE_COUNT) + const pathlessIndex = cycle % PATHLESS_CHAIN_COUNT + const numericId = 1_000 + pickIndex(random, 9_000) + const category = `cat-${randomSegment(random)}` + const section = `sec-${randomSegment(random)}` + const slugWithSection = `slug-${randomSegment(random)}` + const slugWithoutSection = `slug-${randomSegment(random)}` + const splat = [ + `folder-${randomSegment(random)}`, + `topic-${randomSegment(random)}`, + `file-${randomSegment(random)}`, + ].join('/') + const fallbackValue = `fallback-${randomSegment(random)}` + const caseCode = `case-${randomSegment(random)}` + const reservedSlug = `scope@team:${randomSegment(random)}` + + locations.push( + { + to: `/catalog/products/static-${staticIndex}`, + expected: createRouteMarker('static', `static-${staticIndex}`), + }, + { + to: `/catalog/products/dyn-${dynamicIndex}/$category/$id`, + params: { + category, + id: numericId, + }, + expected: createRouteMarker('dynamic', `dynamic-${dynamicIndex}`), + }, + { + to: `/docs/topic-${optionalWithIndex}/{-$section}/$slug`, + params: { + section, + slug: slugWithSection, + }, + expected: createRouteMarker( + 'optional', + `optional-${optionalWithIndex}`, + ), + }, + { + to: `/docs/topic-${optionalWithoutIndex}/{-$section}/$slug`, + params: { + slug: slugWithoutSection, + }, + expected: createRouteMarker( + 'optional', + `optional-${optionalWithoutIndex}`, + ), + }, + { + to: `/files/bucket-${splatIndex}/$`, + params: { + _splat: splat, + }, + expected: createRouteMarker('splat', `splat-${splatIndex}`), + }, + { + to: `/priority/${fallbackValue}`, + expected: createRouteMarker('priority-fallback', 'fallback'), + }, + { + to: '/case/Sensitive/$code', + params: { + code: caseCode, + }, + expected: createRouteMarker('case-sensitive', 'case-sensitive'), + }, + { + to: '/reserved/$slug', + params: { + slug: reservedSlug, + }, + expected: createRouteMarker('reserved-param', 'reserved-param'), + hrefIncludes: 'scope@team:', + }, + { + to: `/pathless/chain-${pathlessIndex}/leaf`, + expected: createRouteMarker('pathless', `pathless-${pathlessIndex}`), + }, + { + to: `/missing/${cycle}/${randomSegment(random)}`, + expected: createRouteMarker('not-found', 'not-found'), + wait: 'idle', + }, + ) + } + + return locations +} diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/project.json b/benchmarks/client-nav/scenarios/route-matching/solid/project.json new file mode 100644 index 0000000000..54639d44fa --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-route-matching-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/setup.ts b/benchmarks/client-nav/scenarios/route-matching/solid/setup.ts new file mode 100644 index 0000000000..0a67882135 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createRouteMatchingWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createRouteMatchingWorkload('solid', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/route-matching/solid/speed.bench.ts new file mode 100644 index 0000000000..ebdf93f714 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav route-matching', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/route-matching/solid/speed.flame.ts new file mode 100644 index 0000000000..8ca05323af --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/speed.flame.ts @@ -0,0 +1,19 @@ +import { installRouteMatchingFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installRouteMatchingFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/src/app.tsx b/benchmarks/client-nav/scenarios/route-matching/solid/src/app.tsx new file mode 100644 index 0000000000..7c5f5713f4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/src/app.tsx @@ -0,0 +1,21 @@ +import { RouterProvider } from '@tanstack/solid-router' +import { render } from 'solid-js/web' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/src/route-components.tsx b/benchmarks/client-nav/scenarios/route-matching/solid/src/route-components.tsx new file mode 100644 index 0000000000..f90de6c3d6 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/src/route-components.tsx @@ -0,0 +1,21 @@ +import { createRouteMarker, type RouteKind } from '../../shared' + +type MarkerProps = { + kind: RouteKind + marker: string +} + +function RouteMarker(props: MarkerProps) { + return
+} + +export function createMarkerComponent(kind: RouteKind, marker: string) { + return function BenchRouteMarker() { + return + } +} + +export function RootNotFoundComponent() { + const marker = createRouteMarker('not-found', 'not-found') + return +} diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/route-matching/solid/src/routeTree.tsx new file mode 100644 index 0000000000..b667566067 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/src/routeTree.tsx @@ -0,0 +1,193 @@ +import { createRoute } from '@tanstack/solid-router' +import { + DYNAMIC_ROUTE_COUNT, + OPTIONAL_ROUTE_COUNT, + PATHLESS_CHAIN_COUNT, + SPLAT_ROUTE_COUNT, + STATIC_ROUTE_COUNT, + createRouteMarker, +} from '../../shared' +import { + parseCatalogParams, + parseCodeParams, + parseDocsParams, + parsePriorityFallbackParams, + parsePriorityValueParams, + parseSlugParams, + stringifyCatalogParams, + stringifyCodeParams, + stringifyDocsParams, + stringifyPriorityFallbackParams, + stringifyPriorityValueParams, + stringifySlugParams, +} from '../../route-params' +import { createMarkerComponent } from './route-components' +import { Route as rootRoute } from './routes/__root' + +const staticRoutes = Array.from({ length: STATIC_ROUTE_COUNT }, (_, index) => { + const marker = createRouteMarker('static', `static-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/catalog/products/static-${index}`, + component: createMarkerComponent(marker.kind, marker.value), + }) +}) + +const dynamicRoutes = Array.from( + { length: DYNAMIC_ROUTE_COUNT }, + (_, index) => { + const marker = createRouteMarker('dynamic', `dynamic-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/catalog/products/dyn-${index}/$category/$id`, + params: { + parse: parseCatalogParams, + stringify: stringifyCatalogParams, + }, + component: createMarkerComponent(marker.kind, marker.value), + }) + }, +) + +const optionalRoutes = Array.from( + { length: OPTIONAL_ROUTE_COUNT }, + (_, index) => { + const marker = createRouteMarker('optional', `optional-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/docs/topic-${index}/{-$section}/$slug`, + params: { + parse: parseDocsParams, + stringify: stringifyDocsParams, + }, + component: createMarkerComponent(marker.kind, marker.value), + }) + }, +) + +const splatRoutes = Array.from({ length: SPLAT_ROUTE_COUNT }, (_, index) => { + const marker = createRouteMarker('splat', `splat-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/files/bucket-${index}/$`, + component: createMarkerComponent(marker.kind, marker.value), + }) +}) + +const priorityPrimaryMarker = createRouteMarker('priority-primary', 'primary') +const priorityPrimaryRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/priority/$value', + params: { + parse: parsePriorityValueParams, + priority: 20, + stringify: stringifyPriorityValueParams, + }, + component: createMarkerComponent( + priorityPrimaryMarker.kind, + priorityPrimaryMarker.value, + ), +}) + +const priorityFallbackMarker = createRouteMarker( + 'priority-fallback', + 'fallback', +) +const priorityFallbackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/priority/$fallback', + params: { + parse: parsePriorityFallbackParams, + priority: 1, + stringify: stringifyPriorityFallbackParams, + }, + component: createMarkerComponent( + priorityFallbackMarker.kind, + priorityFallbackMarker.value, + ), +}) + +const caseSensitiveMarker = createRouteMarker( + 'case-sensitive', + 'case-sensitive', +) +const caseSensitiveRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/case/Sensitive/$code', + caseSensitive: true, + params: { + parse: parseCodeParams, + stringify: stringifyCodeParams, + }, + component: createMarkerComponent( + caseSensitiveMarker.kind, + caseSensitiveMarker.value, + ), +}) + +const reservedParamMarker = createRouteMarker( + 'reserved-param', + 'reserved-param', +) +const reservedParamRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/reserved/$slug', + params: { + parse: parseSlugParams, + stringify: stringifySlugParams, + }, + component: createMarkerComponent( + reservedParamMarker.kind, + reservedParamMarker.value, + ), +}) + +function createPathlessChain(index: number) { + const first = createRoute({ + getParentRoute: () => rootRoute, + id: `pathless-${index}-0`, + }) + const second = createRoute({ + getParentRoute: () => first, + id: `pathless-${index}-1`, + }) + const third = createRoute({ + getParentRoute: () => second, + id: `pathless-${index}-2`, + }) + const fourth = createRoute({ + getParentRoute: () => third, + id: `pathless-${index}-3`, + }) + const marker = createRouteMarker('pathless', `pathless-${index}`) + const leaf = createRoute({ + getParentRoute: () => fourth, + path: `/pathless/chain-${index}/leaf`, + component: createMarkerComponent(marker.kind, marker.value), + }) + + return first.addChildren([ + second.addChildren([third.addChildren([fourth.addChildren([leaf])])]), + ]) +} + +const pathlessChains = Array.from( + { length: PATHLESS_CHAIN_COUNT }, + (_, index) => createPathlessChain(index), +) + +export const routeTree = rootRoute.addChildren([ + ...staticRoutes, + ...dynamicRoutes, + ...optionalRoutes, + ...splatRoutes, + priorityPrimaryRoute, + priorityFallbackRoute, + caseSensitiveRoute, + reservedParamRoute, + ...pathlessChains, +]) diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/src/router.tsx b/benchmarks/client-nav/scenarios/route-matching/solid/src/router.tsx new file mode 100644 index 0000000000..23542c577b --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/src/router.tsx @@ -0,0 +1,19 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { INITIAL_ROUTE_PATH } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [INITIAL_ROUTE_PATH], + }), + pathParamsAllowedCharacters: ['@', ':'], + routeTree: routeTree as any, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/route-matching/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..16d22ad55f --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/src/routes/__root.tsx @@ -0,0 +1,11 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' +import { RootNotFoundComponent } from '../route-components' + +export const Route = createRootRoute({ + component: RootComponent, + notFoundComponent: RootNotFoundComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/tsconfig.json b/benchmarks/client-nav/scenarios/route-matching/solid/tsconfig.json new file mode 100644 index 0000000000..8a2c232fdb --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/route-matching/solid/vite.config.ts b/benchmarks/client-nav/scenarios/route-matching/solid/vite.config.ts new file mode 100644 index 0000000000..dc780d2a44 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/solid/vite.config.ts @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav route-matching (solid)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/project.json b/benchmarks/client-nav/scenarios/route-matching/vue/project.json new file mode 100644 index 0000000000..72125e2cf0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-route-matching-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/setup.ts b/benchmarks/client-nav/scenarios/route-matching/vue/setup.ts new file mode 100644 index 0000000000..9a7ee86158 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createRouteMatchingWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createRouteMatchingWorkload('vue', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/route-matching/vue/speed.bench.ts new file mode 100644 index 0000000000..ebdf93f714 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav route-matching', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/route-matching/vue/speed.flame.ts new file mode 100644 index 0000000000..8ca05323af --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/speed.flame.ts @@ -0,0 +1,19 @@ +import { installRouteMatchingFlameGlobals, window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 +const restoreGlobals = installRouteMatchingFlameGlobals() + +try { + await workload.sanity() + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + restoreGlobals() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/src/app.tsx b/benchmarks/client-nav/scenarios/route-matching/vue/src/app.tsx new file mode 100644 index 0000000000..f72ac4e379 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/src/app.tsx @@ -0,0 +1,28 @@ +import { RouterProvider } from '@tanstack/vue-router' +import { createApp } from 'vue' +import { getRouter } from './router' +import type {} from '@tanstack/router-core' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = createApp({ + setup() { + return () => + }, + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/src/route-components.tsx b/benchmarks/client-nav/scenarios/route-matching/vue/src/route-components.tsx new file mode 100644 index 0000000000..c1b530794a --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/src/route-components.tsx @@ -0,0 +1,27 @@ +import * as Vue from 'vue' +import { createRouteMarker, type RouteKind } from '../../shared' + +type MarkerProps = { + kind: RouteKind + marker: string +} + +function createRouteMarkerElement(props: MarkerProps) { + return
+} + +export function createMarkerComponent(kind: RouteKind, marker: string) { + return Vue.defineComponent({ + setup() { + return () => createRouteMarkerElement({ kind, marker }) + }, + }) +} + +export const RootNotFoundComponent = Vue.defineComponent({ + setup() { + const marker = createRouteMarker('not-found', 'not-found') + return () => + createRouteMarkerElement({ kind: marker.kind, marker: marker.value }) + }, +}) diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/route-matching/vue/src/routeTree.tsx new file mode 100644 index 0000000000..153a933449 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/src/routeTree.tsx @@ -0,0 +1,193 @@ +import { createRoute } from '@tanstack/vue-router' +import { + DYNAMIC_ROUTE_COUNT, + OPTIONAL_ROUTE_COUNT, + PATHLESS_CHAIN_COUNT, + SPLAT_ROUTE_COUNT, + STATIC_ROUTE_COUNT, + createRouteMarker, +} from '../../shared' +import { + parseCatalogParams, + parseCodeParams, + parseDocsParams, + parsePriorityFallbackParams, + parsePriorityValueParams, + parseSlugParams, + stringifyCatalogParams, + stringifyCodeParams, + stringifyDocsParams, + stringifyPriorityFallbackParams, + stringifyPriorityValueParams, + stringifySlugParams, +} from '../../route-params' +import { createMarkerComponent } from './route-components' +import { Route as rootRoute } from './routes/__root' + +const staticRoutes = Array.from({ length: STATIC_ROUTE_COUNT }, (_, index) => { + const marker = createRouteMarker('static', `static-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/catalog/products/static-${index}`, + component: createMarkerComponent(marker.kind, marker.value), + }) +}) + +const dynamicRoutes = Array.from( + { length: DYNAMIC_ROUTE_COUNT }, + (_, index) => { + const marker = createRouteMarker('dynamic', `dynamic-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/catalog/products/dyn-${index}/$category/$id`, + params: { + parse: parseCatalogParams, + stringify: stringifyCatalogParams, + }, + component: createMarkerComponent(marker.kind, marker.value), + }) + }, +) + +const optionalRoutes = Array.from( + { length: OPTIONAL_ROUTE_COUNT }, + (_, index) => { + const marker = createRouteMarker('optional', `optional-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/docs/topic-${index}/{-$section}/$slug`, + params: { + parse: parseDocsParams, + stringify: stringifyDocsParams, + }, + component: createMarkerComponent(marker.kind, marker.value), + }) + }, +) + +const splatRoutes = Array.from({ length: SPLAT_ROUTE_COUNT }, (_, index) => { + const marker = createRouteMarker('splat', `splat-${index}`) + + return createRoute({ + getParentRoute: () => rootRoute, + path: `/files/bucket-${index}/$`, + component: createMarkerComponent(marker.kind, marker.value), + }) +}) + +const priorityPrimaryMarker = createRouteMarker('priority-primary', 'primary') +const priorityPrimaryRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/priority/$value', + params: { + parse: parsePriorityValueParams, + priority: 20, + stringify: stringifyPriorityValueParams, + }, + component: createMarkerComponent( + priorityPrimaryMarker.kind, + priorityPrimaryMarker.value, + ), +}) + +const priorityFallbackMarker = createRouteMarker( + 'priority-fallback', + 'fallback', +) +const priorityFallbackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/priority/$fallback', + params: { + parse: parsePriorityFallbackParams, + priority: 1, + stringify: stringifyPriorityFallbackParams, + }, + component: createMarkerComponent( + priorityFallbackMarker.kind, + priorityFallbackMarker.value, + ), +}) + +const caseSensitiveMarker = createRouteMarker( + 'case-sensitive', + 'case-sensitive', +) +const caseSensitiveRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/case/Sensitive/$code', + caseSensitive: true, + params: { + parse: parseCodeParams, + stringify: stringifyCodeParams, + }, + component: createMarkerComponent( + caseSensitiveMarker.kind, + caseSensitiveMarker.value, + ), +}) + +const reservedParamMarker = createRouteMarker( + 'reserved-param', + 'reserved-param', +) +const reservedParamRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/reserved/$slug', + params: { + parse: parseSlugParams, + stringify: stringifySlugParams, + }, + component: createMarkerComponent( + reservedParamMarker.kind, + reservedParamMarker.value, + ), +}) + +function createPathlessChain(index: number) { + const first = createRoute({ + getParentRoute: () => rootRoute, + id: `pathless-${index}-0`, + }) + const second = createRoute({ + getParentRoute: () => first, + id: `pathless-${index}-1`, + }) + const third = createRoute({ + getParentRoute: () => second, + id: `pathless-${index}-2`, + }) + const fourth = createRoute({ + getParentRoute: () => third, + id: `pathless-${index}-3`, + }) + const marker = createRouteMarker('pathless', `pathless-${index}`) + const leaf = createRoute({ + getParentRoute: () => fourth, + path: `/pathless/chain-${index}/leaf`, + component: createMarkerComponent(marker.kind, marker.value), + }) + + return first.addChildren([ + second.addChildren([third.addChildren([fourth.addChildren([leaf])])]), + ]) +} + +const pathlessChains = Array.from( + { length: PATHLESS_CHAIN_COUNT }, + (_, index) => createPathlessChain(index), +) + +export const routeTree = rootRoute.addChildren([ + ...staticRoutes, + ...dynamicRoutes, + ...optionalRoutes, + ...splatRoutes, + priorityPrimaryRoute, + priorityFallbackRoute, + caseSensitiveRoute, + reservedParamRoute, + ...pathlessChains, +]) diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/src/router.tsx b/benchmarks/client-nav/scenarios/route-matching/vue/src/router.tsx new file mode 100644 index 0000000000..c231198cb9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/src/router.tsx @@ -0,0 +1,19 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { INITIAL_ROUTE_PATH } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [INITIAL_ROUTE_PATH], + }), + pathParamsAllowedCharacters: ['@', ':'], + routeTree: routeTree as any, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/route-matching/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..c1ff4a880b --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/src/routes/__root.tsx @@ -0,0 +1,11 @@ +import { Outlet, createRootRoute } from '@tanstack/vue-router' +import { RootNotFoundComponent } from '../route-components' + +export const Route = createRootRoute({ + component: RootComponent, + notFoundComponent: RootNotFoundComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/tsconfig.json b/benchmarks/client-nav/scenarios/route-matching/vue/tsconfig.json new file mode 100644 index 0000000000..a83100c64e --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/route-matching/vue/vite.config.ts b/benchmarks/client-nav/scenarios/route-matching/vue/vite.config.ts new file mode 100644 index 0000000000..78032e7c13 --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/vue/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav route-matching (vue)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/route-matching/workload.ts b/benchmarks/client-nav/scenarios/route-matching/workload.ts new file mode 100644 index 0000000000..2e29869dec --- /dev/null +++ b/benchmarks/client-nav/scenarios/route-matching/workload.ts @@ -0,0 +1,135 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import { + INITIAL_ROUTE_MARKER, + createRouteMatchingLocations, + type RouteMarker, + type RouteMatchingLocation, +} from './shared' + +function readRouteMarker(container: ParentNode) { + const element = container.querySelector('[data-bench-route]') + + if (!element) { + return undefined + } + + return { + kind: element.dataset.benchRoute, + value: element.dataset.benchMarker, + } +} + +function formatRouteMarker(marker: RouteMarker) { + return `${marker.kind}/${marker.value}` +} + +export function createRouteMatchingWorkload( + framework: Framework, + mountTestApp: MountTestApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp }) + const locations = createRouteMatchingLocations() + let previousMarker: RouteMarker | undefined = undefined + + function assertRouteMarker(expected: RouteMarker) { + const actual = readRouteMarker(lifecycle.getContainer()) + + if (actual?.kind !== expected.kind || actual.value !== expected.value) { + throw new Error( + `Expected route marker ${formatRouteMarker(expected)}, got ${actual?.kind ?? 'missing'}/${actual?.value ?? 'missing'}`, + ) + } + } + + async function waitForRouteMarker(expected: RouteMarker) { + await lifecycle.waitForCounter( + () => { + const actual = readRouteMarker(lifecycle.getContainer()) + return actual?.kind === expected.kind && actual.value === expected.value + ? 1 + : 0 + }, + 1, + { + label: `route marker ${formatRouteMarker(expected)}`, + }, + ) + + assertRouteMarker(expected) + } + + async function navigateTo(location: RouteMatchingLocation) { + await lifecycle.navigate( + { + to: location.to, + params: location.params, + replace: true, + }, + { + label: `navigate ${formatRouteMarker(location.expected)}`, + wait: location.wait ?? 'rendered', + }, + ) + + await waitForRouteMarker(location.expected) + + if ( + previousMarker && + previousMarker.kind === location.expected.kind && + previousMarker.value === location.expected.value + ) { + throw new Error( + `Route marker did not change from ${formatRouteMarker(location.expected)}`, + ) + } + + if ( + location.hrefIncludes && + !lifecycle.getRouter().state.location.href.includes(location.hrefIncludes) + ) { + throw new Error( + `Expected href to include ${location.hrefIncludes}, got ${lifecycle.getRouter().state.location.href}`, + ) + } + + previousMarker = location.expected + } + + async function before() { + previousMarker = undefined + await lifecycle.before() + await waitForRouteMarker(INITIAL_ROUTE_MARKER) + previousMarker = INITIAL_ROUTE_MARKER + } + + async function run() { + for (const location of locations) { + await navigateTo(location) + } + } + + async function sanity() { + await before() + + try { + await run() + } finally { + await lifecycle.after() + } + } + + return { + name: `client route matching loop (${framework})`, + before, + run, + sanity, + after: lifecycle.after, + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/flame-jsdom.ts b/benchmarks/client-nav/scenarios/scroll-restoration/flame-jsdom.ts new file mode 100644 index 0000000000..c32fbb79b1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/flame-jsdom.ts @@ -0,0 +1,3 @@ +import { window } from '../../jsdom.ts' + +export { window } diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/project.json b/benchmarks/client-nav/scenarios/scroll-restoration/react/project.json new file mode 100644 index 0000000000..161db905f9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-scroll-restoration-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/setup.ts b/benchmarks/client-nav/scenarios/scroll-restoration/react/setup.ts new file mode 100644 index 0000000000..4632b2b4d2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createScrollRestorationWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createScrollRestorationWorkload( + 'react', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/speed.bench.ts b/benchmarks/client-nav/scenarios/scroll-restoration/react/speed.bench.ts new file mode 100644 index 0000000000..5685270785 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav scroll-restoration', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/speed.flame.ts b/benchmarks/client-nav/scenarios/scroll-restoration/react/speed.flame.ts new file mode 100644 index 0000000000..e97f364a54 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/app.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/app.tsx new file mode 100644 index 0000000000..538a2c129e --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/app.tsx @@ -0,0 +1,23 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routeTree.tsx new file mode 100644 index 0000000000..389de27032 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routeTree.tsx @@ -0,0 +1,9 @@ +import { rootRoute } from './routes/__root' +import { detailRoute } from './routes/scroll.list.$listId.detail.$itemId' +import { listRoute } from './routes/scroll.list.$listId' +import { scrollRoute } from './routes/scroll' +import { staticRoute } from './routes/scroll.static' + +export const routeTree = rootRoute.addChildren([ + scrollRoute.addChildren([listRoute.addChildren([detailRoute]), staticRoute]), +]) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/router.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/router.tsx new file mode 100644 index 0000000000..a76ecffb4b --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/router.tsx @@ -0,0 +1,25 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { + SCROLL_START_PATH, + createScrollToTopSelectors, + getScrollRestorationKey, +} from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [SCROLL_START_PATH], + }), + scrollRestoration: true, + getScrollRestorationKey, + scrollToTopSelectors: createScrollToTopSelectors(), + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/__root.tsx new file mode 100644 index 0000000000..edc04c397b --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.list.$listId.detail.$itemId.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.list.$listId.detail.$itemId.tsx new file mode 100644 index 0000000000..b9a8e54a28 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.list.$listId.detail.$itemId.tsx @@ -0,0 +1,48 @@ +import { createRoute } from '@tanstack/react-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + getHashAnchorId, + parseScrollDetailParams, + runScrollRenderComputation, + scrollFillerRows, + stringifyScrollDetailParams, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { listRoute } from './scroll.list.$listId' + +export const detailRoute = createRoute({ + getParentRoute: () => listRoute, + path: SCROLL_ROUTE_PATHS.detailChild, + params: { + parse: parseScrollDetailParams, + stringify: stringifyScrollDetailParams, + }, + component: DetailPage, +}) + +function DetailPage() { + const params = detailRoute.useParams() + const hashId = getHashAnchorId(params.itemId) + const checksum = runScrollRenderComputation(params.itemId.length * 17) + + return ( +
+
+ +

{`Detail ${params.itemId}`}

+ {scrollFillerRows.map((row) => ( +

{`Detail row ${row}`}

+ ))} +
+
+ ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.list.$listId.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.list.$listId.tsx new file mode 100644 index 0000000000..134867ef8a --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.list.$listId.tsx @@ -0,0 +1,44 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + parseScrollListParams, + runScrollRenderComputation, + scrollFillerRows, + stringifyScrollListParams, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { scrollRoute } from './scroll' + +export const listRoute = createRoute({ + getParentRoute: () => scrollRoute, + path: SCROLL_ROUTE_PATHS.listChild, + params: { + parse: parseScrollListParams, + stringify: stringifyScrollListParams, + }, + component: ListPage, +}) + +function ListPage() { + const params = listRoute.useParams() + const checksum = runScrollRenderComputation(params.listId.length) + + return ( +
+
+ + {scrollFillerRows.map((row) => ( +
+ {`List ${params.listId} row ${row}`} +
+ ))} + +
+
+ ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.static.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.static.tsx new file mode 100644 index 0000000000..0b92d4903b --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.static.tsx @@ -0,0 +1,30 @@ +import { createRoute } from '@tanstack/react-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + scrollFillerRows, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { scrollRoute } from './scroll' + +export const staticRoute = createRoute({ + getParentRoute: () => scrollRoute, + path: SCROLL_ROUTE_PATHS.staticChild, + component: StaticPage, +}) + +function StaticPage() { + return ( +
+
+ + {scrollFillerRows.map((row) => ( +

{`Static row ${row}`}

+ ))} +
+
+ ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.tsx new file mode 100644 index 0000000000..df0624f709 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/routes/scroll.tsx @@ -0,0 +1,65 @@ +import { Link, Outlet, createRoute } from '@tanstack/react-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + scrollCycles, + scrollSidebarRows, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { rootRoute } from './__root' + +export const scrollRoute = createRoute({ + getParentRoute: () => rootRoute, + path: SCROLL_ROUTE_PATHS.root, + component: ScrollShell, +}) + +function ScrollShell() { + const firstCycle = scrollCycles[0]! + + return ( +
+ +
+ + +
+ + +
+
+
+ ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/src/scroll-runtime.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/scroll-runtime.tsx new file mode 100644 index 0000000000..21445f30f8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/src/scroll-runtime.tsx @@ -0,0 +1,23 @@ +import { useElementScrollRestoration } from '@tanstack/react-router' +import { + SCROLL_CONTAINER_IDS, + getScrollRestorationKey, + runScrollRenderComputation, + type ScrollContainerKey, +} from '../../shared.ts' + +export function RestoredMarker(props: { id: ScrollContainerKey }) { + const restorationId = SCROLL_CONTAINER_IDS[props.id] + const entry = useElementScrollRestoration({ + id: restorationId, + getKey: getScrollRestorationKey, + }) + + void runScrollRenderComputation(entry?.scrollY ?? 0) + return ( + + ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/tsconfig.json b/benchmarks/client-nav/scenarios/scroll-restoration/react/tsconfig.json new file mode 100644 index 0000000000..e4a372e97c --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../scroll-shim.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/react/vite.config.ts b/benchmarks/client-nav/scenarios/scroll-restoration/react/vite.config.ts new file mode 100644 index 0000000000..92128932ea --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/react/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav scroll-restoration (react)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/scroll-shim.ts b/benchmarks/client-nav/scenarios/scroll-restoration/scroll-shim.ts new file mode 100644 index 0000000000..dc2ad3dc6b --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/scroll-shim.ts @@ -0,0 +1,293 @@ +import type { ScrollPosition } from './shared' + +type PropertySnapshot = { + target: object + key: PropertyKey + descriptor: PropertyDescriptor | undefined +} + +export type ScrollToCall = ScrollPosition & { + target: string + behavior: ScrollBehavior | undefined +} + +export type ScrollIntoViewCall = { + target: string + options: boolean | ScrollIntoViewOptions | undefined +} + +export interface ScrollShimController { + dispatchElementScroll: (element: Element) => void + dispatchWindowScroll: () => void + getScrollIntoViewCalls: () => ReadonlyArray + getScrollToCalls: () => ReadonlyArray + getWindowPosition: () => ScrollPosition + readElementPosition: (element: Element) => ScrollPosition + restore: () => void + setElementPosition: (element: Element, position: ScrollPosition) => void + setWindowPosition: (position: ScrollPosition) => void +} + +function snapshotProperty(target: object, key: PropertyKey): PropertySnapshot { + return { + target, + key, + descriptor: Object.getOwnPropertyDescriptor(target, key), + } +} + +function restoreProperty(snapshot: PropertySnapshot) { + if (snapshot.descriptor) { + Object.defineProperty(snapshot.target, snapshot.key, snapshot.descriptor) + return + } + + Reflect.deleteProperty(snapshot.target, snapshot.key) +} + +function toFiniteNumber(value: unknown, fallback: number) { + const number = Number(value) + + if (!Number.isFinite(number)) { + return fallback + } + + return number +} + +function normalizeScrollToArgs( + current: ScrollPosition, + arg0?: ScrollToOptions | number, + arg1?: number, +): ScrollPosition & { behavior: ScrollBehavior | undefined } { + if (typeof arg0 === 'object' && arg0) { + return { + scrollLeft: toFiniteNumber(arg0.left, current.scrollLeft), + scrollTop: toFiniteNumber(arg0.top, current.scrollTop), + behavior: arg0.behavior, + } + } + + return { + scrollLeft: toFiniteNumber(arg0, 0), + scrollTop: toFiniteNumber(arg1, 0), + behavior: undefined, + } +} + +function describeElement(element: Element) { + return ( + element.getAttribute('data-scroll-restoration-id') || + element.id || + element.localName + ) +} + +export function installScrollRestorationShims(): ScrollShimController { + const activeWindow = window + const activeDocument = document + const snapshots: Array = [] + const elementPositions = new WeakMap() + const scrollToCalls: Array = [] + const scrollIntoViewCalls: Array = [] + const previousHistoryScrollRestoration = + activeWindow.history.scrollRestoration + let windowPosition: ScrollPosition = { + scrollLeft: toFiniteNumber(activeWindow.scrollX, 0), + scrollTop: toFiniteNumber(activeWindow.scrollY, 0), + } + let restored = false + + const defineTrackedProperty = ( + target: object, + key: PropertyKey, + descriptor: PropertyDescriptor, + ) => { + snapshots.push(snapshotProperty(target, key)) + Object.defineProperty(target, key, descriptor) + } + + const getElementPosition = (element: Element) => { + let position = elementPositions.get(element) + + if (!position) { + position = { + scrollLeft: 0, + scrollTop: 0, + } + elementPositions.set(element, position) + } + + return position + } + + const setWindowPosition = (position: ScrollPosition) => { + windowPosition = { + scrollLeft: position.scrollLeft, + scrollTop: position.scrollTop, + } + } + + const windowScrollTo = (arg0?: ScrollToOptions | number, arg1?: number) => { + const next = normalizeScrollToArgs(windowPosition, arg0, arg1) + setWindowPosition(next) + scrollToCalls.push({ + target: 'window', + behavior: next.behavior, + scrollLeft: next.scrollLeft, + scrollTop: next.scrollTop, + }) + } + + const elementScrollTo = function ( + this: Element, + arg0?: ScrollToOptions | number, + arg1?: number, + ) { + const current = getElementPosition(this) + const next = normalizeScrollToArgs(current, arg0, arg1) + current.scrollLeft = next.scrollLeft + current.scrollTop = next.scrollTop + scrollToCalls.push({ + target: describeElement(this), + behavior: next.behavior, + scrollLeft: next.scrollLeft, + scrollTop: next.scrollTop, + }) + } + + defineTrackedProperty(globalThis, 'scrollTo', { + configurable: true, + value: windowScrollTo, + writable: true, + }) + defineTrackedProperty(activeWindow, 'scrollTo', { + configurable: true, + value: windowScrollTo, + writable: true, + }) + defineTrackedProperty(globalThis, 'addEventListener', { + configurable: true, + value: activeWindow.addEventListener.bind(activeWindow), + writable: true, + }) + defineTrackedProperty(globalThis, 'removeEventListener', { + configurable: true, + value: activeWindow.removeEventListener.bind(activeWindow), + writable: true, + }) + + for (const target of [globalThis, activeWindow]) { + defineTrackedProperty(target, 'scrollX', { + configurable: true, + get() { + return windowPosition.scrollLeft + }, + set(value) { + windowPosition.scrollLeft = toFiniteNumber( + value, + windowPosition.scrollLeft, + ) + }, + }) + defineTrackedProperty(target, 'scrollY', { + configurable: true, + get() { + return windowPosition.scrollTop + }, + set(value) { + windowPosition.scrollTop = toFiniteNumber( + value, + windowPosition.scrollTop, + ) + }, + }) + } + + defineTrackedProperty(Element.prototype, 'scrollTo', { + configurable: true, + value: elementScrollTo, + writable: true, + }) + defineTrackedProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value(this: Element, options?: boolean | ScrollIntoViewOptions) { + scrollIntoViewCalls.push({ + target: describeElement(this), + options, + }) + }, + writable: true, + }) + defineTrackedProperty(Element.prototype, 'scrollLeft', { + configurable: true, + get(this: Element) { + return getElementPosition(this).scrollLeft + }, + set(this: Element, value: unknown) { + getElementPosition(this).scrollLeft = toFiniteNumber( + value, + getElementPosition(this).scrollLeft, + ) + }, + }) + defineTrackedProperty(Element.prototype, 'scrollTop', { + configurable: true, + get(this: Element) { + return getElementPosition(this).scrollTop + }, + set(this: Element, value: unknown) { + getElementPosition(this).scrollTop = toFiniteNumber( + value, + getElementPosition(this).scrollTop, + ) + }, + }) + + return { + dispatchElementScroll(element) { + element.dispatchEvent( + new activeWindow.Event('scroll', { + bubbles: true, + }), + ) + }, + dispatchWindowScroll() { + activeDocument.dispatchEvent( + new activeWindow.Event('scroll', { + bubbles: true, + }), + ) + }, + getScrollIntoViewCalls() { + return scrollIntoViewCalls + }, + getScrollToCalls() { + return scrollToCalls + }, + getWindowPosition() { + return { ...windowPosition } + }, + readElementPosition(element) { + const position = getElementPosition(element) + return { ...position } + }, + restore() { + if (restored) { + return + } + + restored = true + activeWindow.history.scrollRestoration = previousHistoryScrollRestoration + + for (const snapshot of snapshots.reverse()) { + restoreProperty(snapshot) + } + }, + setElementPosition(element, position) { + element.scrollLeft = position.scrollLeft + element.scrollTop = position.scrollTop + }, + setWindowPosition, + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/shared.ts b/benchmarks/client-nav/scenarios/scroll-restoration/shared.ts new file mode 100644 index 0000000000..89706f82e0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/shared.ts @@ -0,0 +1,153 @@ +import type { ParsedLocation } from '@tanstack/router-core' +import { createDeterministicRandom } from '#client-nav/bench-utils' + +export const SCROLL_ROUTE_PATHS = { + root: '/scroll', + list: '/scroll/list/$listId', + detail: '/scroll/list/$listId/detail/$itemId', + static: '/scroll/static', + listChild: 'list/$listId', + detailChild: 'detail/$itemId', + staticChild: 'static', +} as const + +export const SCROLL_START_PATH = SCROLL_ROUTE_PATHS.root + +export type ScrollPage = 'scroll' | 'list' | 'detail' | 'static' + +export const SCROLL_CONTAINER_IDS = { + root: 'scroll-root-container', + resetPanel: 'scroll-reset-panel', + sidebar: 'scroll-sidebar-panel', + list: 'scroll-list-container', + detail: 'scroll-detail-container', + static: 'scroll-static-container', +} as const + +export const SCROLL_CONTAINER_ID_LIST = [ + SCROLL_CONTAINER_IDS.root, + SCROLL_CONTAINER_IDS.resetPanel, + SCROLL_CONTAINER_IDS.sidebar, + SCROLL_CONTAINER_IDS.list, + SCROLL_CONTAINER_IDS.detail, + SCROLL_CONTAINER_IDS.static, +] as const + +export type ScrollContainerId = (typeof SCROLL_CONTAINER_ID_LIST)[number] +export type ScrollTargetId = 'window' | ScrollContainerId +export type ScrollContainerKey = keyof typeof SCROLL_CONTAINER_IDS + +export const scrollFillerRows = Array.from({ length: 18 }, (_, index) => index) +export const scrollSidebarRows = scrollFillerRows.slice(0, 6) + +export interface ScrollPosition { + scrollLeft: number + scrollTop: number +} + +export type ScrollPositions = Partial> + +export interface ScrollCycleInput { + listId: string + detailAId: string + detailBId: string + hashId: string + listPositions: ScrollPositions + detailPositions: ScrollPositions + detailHashPositions: ScrollPositions + detailBPositions: ScrollPositions +} + +const seededPositions = createDeterministicRandom(0x5eed_120c) + +const cycleIds = [ + { listId: 'a', detailAId: 'alpha', detailBId: 'beta' }, + { listId: 'b', detailAId: 'gamma', detailBId: 'delta' }, +] as const + +function createPosition(base: number): ScrollPosition { + return { + scrollLeft: base + Math.floor(seededPositions() * 41), + scrollTop: base * 9 + 120 + Math.floor(seededPositions() * 211), + } +} + +function createPositions(base: number): ScrollPositions { + return { + window: createPosition(base), + [SCROLL_CONTAINER_IDS.root]: createPosition(base + 3), + [SCROLL_CONTAINER_IDS.resetPanel]: createPosition(base + 5), + [SCROLL_CONTAINER_IDS.sidebar]: createPosition(base + 7), + [SCROLL_CONTAINER_IDS.list]: createPosition(base + 11), + [SCROLL_CONTAINER_IDS.detail]: createPosition(base + 13), + [SCROLL_CONTAINER_IDS.static]: createPosition(base + 17), + } +} + +export const scrollCycles: ReadonlyArray = cycleIds.map( + (ids, index) => ({ + ...ids, + hashId: getHashAnchorId(ids.detailAId), + listPositions: createPositions(20 + index * 40), + detailPositions: createPositions(30 + index * 40), + detailHashPositions: createPositions(40 + index * 40), + detailBPositions: createPositions(50 + index * 40), + }), +) + +export function getHashAnchorId(itemId: string) { + return `scroll-anchor-${itemId}` +} + +export function getScrollRestorationKey(location: ParsedLocation) { + return location.pathname +} + +export function getScrollRestorationSelector(id: ScrollContainerId) { + return `[data-scroll-restoration-id="${id}"]` +} + +export function createScrollToTopSelectors() { + return [ + getScrollRestorationSelector(SCROLL_CONTAINER_IDS.resetPanel), + getScrollRestorationSelector(SCROLL_CONTAINER_IDS.list), + () => + document.querySelector( + getScrollRestorationSelector(SCROLL_CONTAINER_IDS.detail), + ), + ] +} + +export function normalizeScrollSegment(value: unknown, fallback: string) { + if (typeof value === 'string' && value.length > 0) { + return value + } + + return fallback +} + +export function parseScrollListParams(params: { listId: string }) { + return { + listId: normalizeScrollSegment(params.listId, 'missing-list'), + } +} + +export const stringifyScrollListParams = parseScrollListParams + +export function parseScrollDetailParams(params: { itemId: string }) { + return { + itemId: normalizeScrollSegment(params.itemId, 'missing-item'), + } +} + +export const stringifyScrollDetailParams = parseScrollDetailParams + +export function runScrollRenderComputation(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 24; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/project.json b/benchmarks/client-nav/scenarios/scroll-restoration/solid/project.json new file mode 100644 index 0000000000..9f26aab752 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-scroll-restoration-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/setup.ts b/benchmarks/client-nav/scenarios/scroll-restoration/solid/setup.ts new file mode 100644 index 0000000000..23f5096f8c --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createScrollRestorationWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createScrollRestorationWorkload( + 'solid', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/scroll-restoration/solid/speed.bench.ts new file mode 100644 index 0000000000..5685270785 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav scroll-restoration', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/scroll-restoration/solid/speed.flame.ts new file mode 100644 index 0000000000..e97f364a54 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/app.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/app.tsx new file mode 100644 index 0000000000..e7102b78fe --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/app.tsx @@ -0,0 +1,21 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routeTree.tsx new file mode 100644 index 0000000000..389de27032 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routeTree.tsx @@ -0,0 +1,9 @@ +import { rootRoute } from './routes/__root' +import { detailRoute } from './routes/scroll.list.$listId.detail.$itemId' +import { listRoute } from './routes/scroll.list.$listId' +import { scrollRoute } from './routes/scroll' +import { staticRoute } from './routes/scroll.static' + +export const routeTree = rootRoute.addChildren([ + scrollRoute.addChildren([listRoute.addChildren([detailRoute]), staticRoute]), +]) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/router.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/router.tsx new file mode 100644 index 0000000000..cdd983c765 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/router.tsx @@ -0,0 +1,25 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { + SCROLL_START_PATH, + createScrollToTopSelectors, + getScrollRestorationKey, +} from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [SCROLL_START_PATH], + }), + scrollRestoration: true, + getScrollRestorationKey, + scrollToTopSelectors: createScrollToTopSelectors(), + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..1e9054643e --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.list.$listId.detail.$itemId.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.list.$listId.detail.$itemId.tsx new file mode 100644 index 0000000000..e128ee4a1b --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.list.$listId.detail.$itemId.tsx @@ -0,0 +1,51 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + getHashAnchorId, + parseScrollDetailParams, + runScrollRenderComputation, + scrollFillerRows, + stringifyScrollDetailParams, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { listRoute } from './scroll.list.$listId' + +export const detailRoute = createRoute({ + getParentRoute: () => listRoute, + path: SCROLL_ROUTE_PATHS.detailChild, + params: { + parse: parseScrollDetailParams, + stringify: stringifyScrollDetailParams, + }, + component: DetailPage, +}) + +function DetailPage() { + const params = detailRoute.useParams() + + return ( +
+
+ +

{`Detail ${params().itemId}`}

+ + {(row) =>

{`Detail row ${row}`}

} +
+
+
+ ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.list.$listId.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.list.$listId.tsx new file mode 100644 index 0000000000..b0de5b4165 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.list.$listId.tsx @@ -0,0 +1,42 @@ +import { For } from 'solid-js' +import { Outlet, createRoute } from '@tanstack/solid-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + parseScrollListParams, + runScrollRenderComputation, + scrollFillerRows, + stringifyScrollListParams, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { scrollRoute } from './scroll' + +export const listRoute = createRoute({ + getParentRoute: () => scrollRoute, + path: SCROLL_ROUTE_PATHS.listChild, + params: { + parse: parseScrollListParams, + stringify: stringifyScrollListParams, + }, + component: ListPage, +}) + +function ListPage() { + const params = listRoute.useParams() + + return ( +
+
+ + + {(row) =>
{`List ${params().listId} row ${row}`}
} +
+ +
+
+ ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.static.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.static.tsx new file mode 100644 index 0000000000..d7d677c9f3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.static.tsx @@ -0,0 +1,31 @@ +import { For } from 'solid-js' +import { createRoute } from '@tanstack/solid-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + scrollFillerRows, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { scrollRoute } from './scroll' + +export const staticRoute = createRoute({ + getParentRoute: () => scrollRoute, + path: SCROLL_ROUTE_PATHS.staticChild, + component: StaticPage, +}) + +function StaticPage() { + return ( +
+
+ + + {(row) =>

{`Static row ${row}`}

} +
+
+
+ ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.tsx new file mode 100644 index 0000000000..9b01b8913d --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/routes/scroll.tsx @@ -0,0 +1,66 @@ +import { For } from 'solid-js' +import { Link, Outlet, createRoute } from '@tanstack/solid-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + scrollCycles, + scrollSidebarRows, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { rootRoute } from './__root' + +export const scrollRoute = createRoute({ + getParentRoute: () => rootRoute, + path: SCROLL_ROUTE_PATHS.root, + component: ScrollShell, +}) + +function ScrollShell() { + const firstCycle = scrollCycles[0]! + + return ( +
+ +
+ + +
+ + +
+
+
+ ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/scroll-runtime.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/scroll-runtime.tsx new file mode 100644 index 0000000000..9c205f29c5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/src/scroll-runtime.tsx @@ -0,0 +1,23 @@ +import { useElementScrollRestoration } from '@tanstack/solid-router' +import { + SCROLL_CONTAINER_IDS, + getScrollRestorationKey, + runScrollRenderComputation, + type ScrollContainerKey, +} from '../../shared.ts' + +export function RestoredMarker(props: { id: ScrollContainerKey }) { + const restorationId = SCROLL_CONTAINER_IDS[props.id] + const entry = useElementScrollRestoration({ + id: restorationId, + getKey: getScrollRestorationKey, + }) + + void runScrollRenderComputation(entry?.scrollY ?? 0) + return ( + + ) +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/tsconfig.json b/benchmarks/client-nav/scenarios/scroll-restoration/solid/tsconfig.json new file mode 100644 index 0000000000..f733bfcc8c --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../scroll-shim.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/solid/vite.config.ts b/benchmarks/client-nav/scenarios/scroll-restoration/solid/vite.config.ts new file mode 100644 index 0000000000..d9b46b193d --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/solid/vite.config.ts @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav scroll-restoration (solid)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/project.json b/benchmarks/client-nav/scenarios/scroll-restoration/vue/project.json new file mode 100644 index 0000000000..492ba57200 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-scroll-restoration-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/setup.ts b/benchmarks/client-nav/scenarios/scroll-restoration/vue/setup.ts new file mode 100644 index 0000000000..715eef8b92 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/setup.ts @@ -0,0 +1,13 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type * as App from './src/app' +import { createScrollRestorationWorkload } from '../workload' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload: ClientNavWorkload = createScrollRestorationWorkload( + 'vue', + mountTestApp, +) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/scroll-restoration/vue/speed.bench.ts new file mode 100644 index 0000000000..5685270785 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav scroll-restoration', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/scroll-restoration/vue/speed.flame.ts new file mode 100644 index 0000000000..e97f364a54 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../flame-jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/app.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/app.tsx new file mode 100644 index 0000000000..300beed62c --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/app.tsx @@ -0,0 +1,25 @@ +import { RouterProvider } from '@tanstack/vue-router' +import { createApp } from 'vue' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routeTree.tsx new file mode 100644 index 0000000000..389de27032 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routeTree.tsx @@ -0,0 +1,9 @@ +import { rootRoute } from './routes/__root' +import { detailRoute } from './routes/scroll.list.$listId.detail.$itemId' +import { listRoute } from './routes/scroll.list.$listId' +import { scrollRoute } from './routes/scroll' +import { staticRoute } from './routes/scroll.static' + +export const routeTree = rootRoute.addChildren([ + scrollRoute.addChildren([listRoute.addChildren([detailRoute]), staticRoute]), +]) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/router.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/router.tsx new file mode 100644 index 0000000000..0bb1988167 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/router.tsx @@ -0,0 +1,25 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { + SCROLL_START_PATH, + createScrollToTopSelectors, + getScrollRestorationKey, +} from '../../shared.ts' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [SCROLL_START_PATH], + }), + scrollRestoration: true, + getScrollRestorationKey, + scrollToTopSelectors: createScrollToTopSelectors(), + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..1904cf90ef --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/__root.tsx @@ -0,0 +1,12 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +const Root = Vue.defineComponent({ + setup() { + return () => + }, +}) + +export const rootRoute = createRootRoute({ + component: Root, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.list.$listId.detail.$itemId.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.list.$listId.detail.$itemId.tsx new file mode 100644 index 0000000000..e13840ace5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.list.$listId.detail.$itemId.tsx @@ -0,0 +1,55 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + getHashAnchorId, + parseScrollDetailParams, + runScrollRenderComputation, + scrollFillerRows, + stringifyScrollDetailParams, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { listRoute } from './scroll.list.$listId' + +const DetailPage = Vue.defineComponent({ + setup() { + const params = detailRoute.useParams() + + return () => { + const listId = params.value.listId + const itemId = params.value.itemId + const checksum = runScrollRenderComputation(itemId.length * 17) + + return ( +
+
+ +

{`Detail ${itemId}`}

+ {scrollFillerRows.map((row) => ( +

{`Detail row ${row}`}

+ ))} +
+
+ ) + } + }, +}) + +export const detailRoute = createRoute({ + getParentRoute: () => listRoute, + path: SCROLL_ROUTE_PATHS.detailChild, + params: { + parse: parseScrollDetailParams, + stringify: stringifyScrollDetailParams, + }, + component: DetailPage, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.list.$listId.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.list.$listId.tsx new file mode 100644 index 0000000000..6670aa535a --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.list.$listId.tsx @@ -0,0 +1,51 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + parseScrollListParams, + runScrollRenderComputation, + scrollFillerRows, + stringifyScrollListParams, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { scrollRoute } from './scroll' + +const ListPage = Vue.defineComponent({ + setup() { + const params = listRoute.useParams() + + return () => { + const listId = params.value.listId + const checksum = runScrollRenderComputation(listId.length) + + return ( +
+
+ + {scrollFillerRows.map((row) => ( +
{`List ${listId} row ${row}`}
+ ))} + +
+
+ ) + } + }, +}) + +export const listRoute = createRoute({ + getParentRoute: () => scrollRoute, + path: SCROLL_ROUTE_PATHS.listChild, + params: { + parse: parseScrollListParams, + stringify: stringifyScrollListParams, + }, + component: ListPage, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.static.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.static.tsx new file mode 100644 index 0000000000..8d7d802c27 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.static.tsx @@ -0,0 +1,33 @@ +import * as Vue from 'vue' +import { createRoute } from '@tanstack/vue-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + scrollFillerRows, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { scrollRoute } from './scroll' + +const StaticPage = Vue.defineComponent({ + setup() { + return () => ( +
+
+ + {scrollFillerRows.map((row) => ( +

{`Static row ${row}`}

+ ))} +
+
+ ) + }, +}) + +export const staticRoute = createRoute({ + getParentRoute: () => scrollRoute, + path: SCROLL_ROUTE_PATHS.staticChild, + component: StaticPage, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.tsx new file mode 100644 index 0000000000..a3dd64bcd9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/routes/scroll.tsx @@ -0,0 +1,68 @@ +import * as Vue from 'vue' +import { Link, Outlet, createRoute } from '@tanstack/vue-router' +import { + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + scrollCycles, + scrollSidebarRows, +} from '../../../shared.ts' +import { RestoredMarker } from '../scroll-runtime' +import { rootRoute } from './__root' + +const ScrollShell = Vue.defineComponent({ + setup() { + const firstCycle = scrollCycles[0]! + + return () => ( +
+ +
+ + +
+ + +
+
+
+ ) + }, +}) + +export const scrollRoute = createRoute({ + getParentRoute: () => rootRoute, + path: SCROLL_ROUTE_PATHS.root, + component: ScrollShell, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/scroll-runtime.tsx b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/scroll-runtime.tsx new file mode 100644 index 0000000000..0de7ae7b38 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/src/scroll-runtime.tsx @@ -0,0 +1,34 @@ +import * as Vue from 'vue' +import { useElementScrollRestoration } from '@tanstack/vue-router' +import { + SCROLL_CONTAINER_IDS, + getScrollRestorationKey, + runScrollRenderComputation, + type ScrollContainerKey, +} from '../../shared.ts' + +export const RestoredMarker = Vue.defineComponent({ + props: { + id: { + type: String as Vue.PropType, + required: true, + }, + }, + setup(props) { + const restorationId = SCROLL_CONTAINER_IDS[props.id] + const entry = useElementScrollRestoration({ + id: restorationId, + getKey: getScrollRestorationKey, + }) + + return () => { + void runScrollRenderComputation(entry?.scrollY ?? 0) + return ( + + ) + } + }, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/tsconfig.json b/benchmarks/client-nav/scenarios/scroll-restoration/vue/tsconfig.json new file mode 100644 index 0000000000..61b67987f4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "setup.ts", + "speed.bench.ts", + "speed.flame.ts", + "vite.config.ts", + "../flame-jsdom.ts", + "../scroll-shim.ts", + "../shared.ts", + "../workload.ts", + "../../../bench-utils.ts", + "../../../benchmark.ts", + "../../../jsdom.ts", + "../../../lifecycle.ts", + "../../../setup-helpers.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/vue/vite.config.ts b/benchmarks/client-nav/scenarios/scroll-restoration/vue/vite.config.ts new file mode 100644 index 0000000000..d08ecfd57b --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/vue/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav scroll-restoration (vue)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/scroll-restoration/workload.ts b/benchmarks/client-nav/scenarios/scroll-restoration/workload.ts new file mode 100644 index 0000000000..1df84a7c4f --- /dev/null +++ b/benchmarks/client-nav/scenarios/scroll-restoration/workload.ts @@ -0,0 +1,344 @@ +import type { NavigateOptions } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import { installScrollRestorationShims } from './scroll-shim' +import { + SCROLL_CONTAINER_ID_LIST, + SCROLL_CONTAINER_IDS, + SCROLL_ROUTE_PATHS, + SCROLL_START_PATH, + scrollCycles, + type ScrollContainerId, + type ScrollPage, + type ScrollPosition, + type ScrollPositions, +} from './shared' +import type { ScrollShimController } from './scroll-shim' + +function formatPosition(position: ScrollPosition) { + return `${position.scrollLeft},${position.scrollTop}` +} + +function assertPosition( + label: string, + actual: ScrollPosition, + expected: ScrollPosition, +) { + if ( + actual.scrollLeft !== expected.scrollLeft || + actual.scrollTop !== expected.scrollTop + ) { + throw new Error( + `${label}: expected ${formatPosition(expected)}, got ${formatPosition(actual)}`, + ) + } +} + +function assertPage(container: ParentNode, page: ScrollPage) { + if (!container.querySelector(`[data-scroll-page="${page}"]`)) { + throw new Error(`Expected scroll page marker ${page}`) + } +} + +export function createScrollRestorationWorkload( + framework: Framework, + mountTestApp: MountTestApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp }) + let scrollShim: ScrollShimController | undefined = undefined + + const getShim = () => { + if (!scrollShim) { + throw new Error('Scroll restoration shims are not installed') + } + + return scrollShim + } + + const hasPage = (page: ScrollPage) => + lifecycle.getContainer().querySelector(`[data-scroll-page="${page}"]`) + ? 1 + : 0 + + async function waitForPage(page: ScrollPage) { + await lifecycle.waitForCounter(() => hasPage(page), 1, { + label: `${page} page marker`, + }) + assertPage(lifecycle.getContainer(), page) + } + + function getScrollElement(id: ScrollContainerId) { + const element = lifecycle + .getContainer() + .querySelector(`[data-scroll-restoration-id="${id}"]`) + + if (!element) { + throw new Error(`Missing scroll restoration element ${id}`) + } + + return element + } + + function applyScrollPositions(positions: ScrollPositions) { + const shim = getShim() + + if (positions.window) { + shim.setWindowPosition(positions.window) + shim.dispatchWindowScroll() + } + + for (const id of SCROLL_CONTAINER_ID_LIST) { + const position = positions[id] + + if (!position) { + continue + } + + const element = lifecycle + .getContainer() + .querySelector(`[data-scroll-restoration-id="${id}"]`) + + if (!element) { + continue + } + + shim.setElementPosition(element, position) + shim.dispatchElementScroll(element) + } + } + + function assertElementPosition( + id: ScrollContainerId, + expected: ScrollPosition, + ) { + const shim = getShim() + const element = getScrollElement(id) + + assertPosition(id, shim.readElementPosition(element), expected) + } + + function assertWindowPosition(label: string, expected: ScrollPosition) { + assertPosition(label, getShim().getWindowPosition(), expected) + } + + async function navigateAndWait( + options: NavigateOptions, + page: ScrollPage, + label: string, + ) { + await lifecycle.navigate(options, { + label, + wait: 'rendered', + }) + await waitForPage(page) + } + + async function goHistory(delta: number, page: ScrollPage, label: string) { + await lifecycle.waitForRender( + () => { + lifecycle.getRouter().history.go(delta) + }, + { label }, + ) + await waitForPage(page) + } + + async function resetToStart() { + await navigateAndWait( + { + to: SCROLL_START_PATH, + replace: true, + }, + 'scroll', + 'reset to scroll start', + ) + + const historyIndex = Number( + lifecycle.getRouter().history.location.state.__TSR_index ?? 0, + ) + + if (historyIndex > 0) { + lifecycle.getRouter().history.go(-historyIndex) + await lifecycle.waitForRouterIdle({ label: 'reset scroll history index' }) + } + } + + async function runCycle( + input: (typeof scrollCycles)[number], + assertScrollEffects: boolean, + ) { + await navigateAndWait( + { + to: SCROLL_ROUTE_PATHS.list, + params: { listId: input.listId }, + }, + 'list', + `list ${input.listId}`, + ) + applyScrollPositions(input.listPositions) + + await navigateAndWait( + { + to: SCROLL_ROUTE_PATHS.detail, + params: { listId: input.listId, itemId: input.detailAId }, + }, + 'detail', + `detail ${input.detailAId}`, + ) + applyScrollPositions(input.detailPositions) + + await goHistory(-1, 'list', `history restore list ${input.listId}`) + + if (assertScrollEffects) { + assertElementPosition( + SCROLL_CONTAINER_IDS.list, + input.listPositions[SCROLL_CONTAINER_IDS.list]!, + ) + } + + const scrollIntoViewCount = getShim().getScrollIntoViewCalls().length + + await navigateAndWait( + { + to: SCROLL_ROUTE_PATHS.detail, + params: { listId: input.listId, itemId: input.detailAId }, + hash: input.hashId, + hashScrollIntoView: { block: 'center', inline: 'nearest' }, + }, + 'detail', + `detail hash ${input.detailAId}`, + ) + + const hashCalls = getShim() + .getScrollIntoViewCalls() + .slice(scrollIntoViewCount) + + if ( + assertScrollEffects && + !hashCalls.some((call) => call.target === input.hashId) + ) { + throw new Error(`Expected hash scrollIntoView for ${input.hashId}`) + } + + applyScrollPositions(input.detailHashPositions) + + await navigateAndWait( + { + to: SCROLL_ROUTE_PATHS.detail, + params: { listId: input.listId, itemId: input.detailBId }, + resetScroll: false, + }, + 'detail', + `detail no reset ${input.detailBId}`, + ) + + if (assertScrollEffects) { + assertWindowPosition( + 'resetScroll false window position', + input.detailHashPositions.window!, + ) + } + + applyScrollPositions(input.detailBPositions) + + await navigateAndWait( + { + to: SCROLL_ROUTE_PATHS.static, + }, + 'static', + 'static reset', + ) + + if (assertScrollEffects) { + assertWindowPosition('default reset window position', { + scrollLeft: 0, + scrollTop: 0, + }) + assertElementPosition(SCROLL_CONTAINER_IDS.resetPanel, { + scrollLeft: 0, + scrollTop: 0, + }) + } + + await goHistory( + -3, + 'list', + `history restore list from static ${input.listId}`, + ) + + if (assertScrollEffects) { + assertElementPosition( + SCROLL_CONTAINER_IDS.list, + input.listPositions[SCROLL_CONTAINER_IDS.list]!, + ) + } + + await resetToStart() + } + + async function before() { + await after() + + const shim = installScrollRestorationShims() + scrollShim = shim + + try { + await lifecycle.before() + await waitForPage('scroll') + getScrollElement(SCROLL_CONTAINER_IDS.root) + getScrollElement(SCROLL_CONTAINER_IDS.resetPanel) + getScrollElement(SCROLL_CONTAINER_IDS.sidebar) + } catch (error) { + scrollShim = undefined + shim.restore() + throw error + } + } + + async function run() { + for (const cycle of scrollCycles) { + await runCycle(cycle, false) + } + } + + async function sanity() { + await before() + + try { + if (lifecycle.getRouter().options.scrollRestoration !== true) { + throw new Error('Expected router scrollRestoration to be enabled') + } + + for (const cycle of scrollCycles) { + await runCycle(cycle, true) + } + } finally { + await after() + } + } + + async function after() { + const shim = scrollShim + scrollShim = undefined + + try { + await lifecycle.after() + } finally { + shim?.restore() + } + } + + return { + name: `client scroll restoration loop (${framework})`, + before, + run, + sanity, + after, + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/project.json b/benchmarks/client-nav/scenarios/search-params/react/project.json new file mode 100644 index 0000000000..e7fe60e5ff --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-search-params-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/setup.ts b/benchmarks/client-nav/scenarios/search-params/react/setup.ts new file mode 100644 index 0000000000..d2aa203335 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createSearchParamsWorkload } from '../shared' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createSearchParamsWorkload('react', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/search-params/react/speed.bench.ts b/benchmarks/client-nav/scenarios/search-params/react/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/search-params/react/speed.flame.ts b/benchmarks/client-nav/scenarios/search-params/react/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/src/app.tsx b/benchmarks/client-nav/scenarios/search-params/react/src/app.tsx new file mode 100644 index 0000000000..083f327062 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/src/app.tsx @@ -0,0 +1,23 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export function mountTestApp(container: HTMLDivElement) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/search-params/react/src/routeTree.gen.ts new file mode 100644 index 0000000000..2e50ddbd64 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/src/routeTree.gen.ts @@ -0,0 +1,144 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ShopRouteImport } from './routes/shop' +import { Route as ShopCompareRouteImport } from './routes/shop.compare' +import { Route as ShopProductsRouteImport } from './routes/shop.products' +import { Route as ShopProductsProductIdRouteImport } from './routes/shop.products.$productId' + +const ShopRoute = ShopRouteImport.update({ + id: '/shop', + path: '/shop', + getParentRoute: () => rootRouteImport, +} as any) +const ShopCompareRoute = ShopCompareRouteImport.update({ + id: '/compare', + path: '/compare', + getParentRoute: () => ShopRoute, +} as any) +const ShopProductsRoute = ShopProductsRouteImport.update({ + id: '/products', + path: '/products', + getParentRoute: () => ShopRoute, +} as any) +const ShopProductsProductIdRoute = ShopProductsProductIdRouteImport.update({ + id: '/$productId', + path: '/$productId', + getParentRoute: () => ShopProductsRoute, +} as any) + +export interface FileRoutesByFullPath { + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRoutesByTo { + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + fileRoutesByTo: FileRoutesByTo + to: + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + id: + | '__root__' + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + ShopRoute: typeof ShopRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/shop': { + id: '/shop' + path: '/shop' + fullPath: '/shop' + preLoaderRoute: typeof ShopRouteImport + parentRoute: typeof rootRouteImport + } + '/shop/compare': { + id: '/shop/compare' + path: '/compare' + fullPath: '/shop/compare' + preLoaderRoute: typeof ShopCompareRouteImport + parentRoute: typeof ShopRoute + } + '/shop/products': { + id: '/shop/products' + path: '/products' + fullPath: '/shop/products' + preLoaderRoute: typeof ShopProductsRouteImport + parentRoute: typeof ShopRoute + } + '/shop/products/$productId': { + id: '/shop/products/$productId' + path: '/$productId' + fullPath: '/shop/products/$productId' + preLoaderRoute: typeof ShopProductsProductIdRouteImport + parentRoute: typeof ShopProductsRoute + } + } +} + +interface ShopProductsRouteChildren { + ShopProductsProductIdRoute: typeof ShopProductsProductIdRoute +} + +const ShopProductsRouteChildren: ShopProductsRouteChildren = { + ShopProductsProductIdRoute: ShopProductsProductIdRoute, +} + +const ShopProductsRouteWithChildren = ShopProductsRoute._addFileChildren( + ShopProductsRouteChildren, +) + +interface ShopRouteChildren { + ShopProductsRoute: typeof ShopProductsRouteWithChildren + ShopCompareRoute: typeof ShopCompareRoute +} + +const ShopRouteChildren: ShopRouteChildren = { + ShopProductsRoute: ShopProductsRouteWithChildren, + ShopCompareRoute: ShopCompareRoute, +} + +const ShopRouteWithChildren = ShopRoute._addFileChildren(ShopRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + ShopRoute: ShopRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/search-params/react/src/router.tsx b/benchmarks/client-nav/scenarios/search-params/react/src/router.tsx new file mode 100644 index 0000000000..0ee5fb2c8d --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/src/router.tsx @@ -0,0 +1,27 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { + initialProductsSearch, + parseJsonSearch, + stringifyJsonSearch, +} from '../../shared' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [ + `/shop/products${stringifyJsonSearch(initialProductsSearch)}`, + ], + }), + parseSearch: parseJsonSearch, + stringifySearch: stringifyJsonSearch, + search: { strict: true }, + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/search-params/react/src/routes/__root.tsx new file mode 100644 index 0000000000..889395056b --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.compare.tsx b/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.compare.tsx new file mode 100644 index 0000000000..c0b4ad2be7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.compare.tsx @@ -0,0 +1,74 @@ +import { + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/react-router' +import { + createCompareLoaderData, + createCompareLoaderDeps, + computeSearchChecksum, + defaultCompareSearchStrip, + formatCompareMarker, + routeSubscriberIds, + selectCompareLoaderDeps, + selectCompareSearch, + tenantSearchKeys, + transientSearchKeys, + validateCompareSearch, + type CompareSearch, +} from '../../../shared' + +export const Route = createFileRoute('/shop/compare')({ + validateSearch: validateCompareSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultCompareSearchStrip), + ], + }, + loaderDeps: createCompareLoaderDeps, + loader: createCompareLoaderData, + staleTime: 60_000, + gcTime: 60_000, + component: ComparePage, +}) + +function CompareSearchSubscriber() { + const selected = Route.useSearch({ + select: selectCompareSearch, + structuralSharing: true, + }) + + void computeSearchChecksum(selected) + return null +} + +function CompareLoaderDepsSubscriber() { + const loaderDeps = Route.useLoaderDeps({ + select: selectCompareLoaderDeps, + structuralSharing: true, + }) + + void computeSearchChecksum(loaderDeps) + return null +} + +function ComparePage() { + const search = Route.useSearch() + const loaderData = Route.useLoaderData() + + return ( + <> + {routeSubscriberIds.map((id) => ( + + ))} + {routeSubscriberIds.map((id) => ( + + ))} +
+ {formatCompareMarker(search, loaderData)} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.products.$productId.tsx b/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.products.$productId.tsx new file mode 100644 index 0000000000..397c567f6c --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.products.$productId.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, stripSearchParams } from '@tanstack/react-router' +import { + computeSearchChecksum, + formatDetailMarker, + routeSubscriberIds, + selectDetailSearch, + transientSearchKeys, + validateDetailSearch, + type DetailSearch, +} from '../../../shared' + +export const Route = createFileRoute('/shop/products/$productId')({ + validateSearch: validateDetailSearch, + search: { + middlewares: [stripSearchParams(transientSearchKeys)], + }, + component: ProductDetailPage, +}) + +function DetailSearchSubscriber() { + const selected = Route.useSearch({ + select: selectDetailSearch, + structuralSharing: true, + }) + + void computeSearchChecksum(selected) + return null +} + +function ProductDetailPage() { + const params = Route.useParams() + const search = Route.useSearch() + + return ( + <> + {routeSubscriberIds.map((id) => ( + + ))} +
+ {formatDetailMarker(params.productId, search)} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.products.tsx b/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.products.tsx new file mode 100644 index 0000000000..1d9013968d --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.products.tsx @@ -0,0 +1,104 @@ +import { + Outlet, + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/react-router' +import { + createProductsLoaderData, + createProductsLoaderDeps, + computeSearchChecksum, + defaultProductsSearchStrip, + formatProductsMarker, + routeSubscriberIds, + selectProductsLoaderData, + selectProductsLoaderDeps, + selectProductsPrimitiveSearch, + selectProductsSearch, + tenantSearchKeys, + transientSearchKeys, + validateProductsSearch, + type ProductsSearch, +} from '../../../shared' + +export const Route = createFileRoute('/shop/products')({ + validateSearch: validateProductsSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultProductsSearchStrip), + ], + }, + loaderDeps: createProductsLoaderDeps, + loader: createProductsLoaderData, + staleTime: 60_000, + gcTime: 60_000, + component: ProductsPage, +}) + +function ProductsSearchSubscriber() { + const selected = Route.useSearch({ + select: selectProductsSearch, + structuralSharing: true, + }) + + void computeSearchChecksum(selected) + return null +} + +function ProductsPrimitiveSubscriber() { + const selected = Route.useSearch({ + select: selectProductsPrimitiveSearch, + structuralSharing: true, + }) + + void computeSearchChecksum(selected) + return null +} + +function ProductsLoaderDepsSubscriber() { + const loaderDeps = Route.useLoaderDeps({ + select: selectProductsLoaderDeps, + structuralSharing: true, + }) + + void computeSearchChecksum(loaderDeps) + return null +} + +function ProductsLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: selectProductsLoaderData, + structuralSharing: true, + }) + + void computeSearchChecksum(loaderData) + return null +} + +function ProductsPage() { + const search = Route.useSearch() + const loaderData = Route.useLoaderData() + + return ( + <> + {routeSubscriberIds.map((id) => ( + + ))} + {routeSubscriberIds.map((id) => ( + + ))} + {routeSubscriberIds.map((id) => ( + + ))} + {routeSubscriberIds.map((id) => ( + + ))} +
+ {formatProductsMarker(search, loaderData)} +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.tsx b/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.tsx new file mode 100644 index 0000000000..ba2a3a07c9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/src/routes/shop.tsx @@ -0,0 +1,85 @@ +import { + Link, + Outlet, + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/react-router' +import { + buildCompareSearch, + buildProductsSearch, + computeSearchChecksum, + defaultShopSearchStrip, + selectShopPrimitiveSearch, + selectShopSearch, + shopSubscriberIds, + tenantSearchKeys, + transientSearchKeys, + validateShopSearch, + type ShopSearchSchema, +} from '../../../shared' + +export const Route = createFileRoute('/shop')({ + validateSearch: validateShopSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultShopSearchStrip), + ], + }, + component: ShopLayout, +}) + +function ShopSearchSubscriber() { + const selected = Route.useSearch({ + select: selectShopSearch, + structuralSharing: true, + }) + + void computeSearchChecksum(selected) + return null +} + +function ShopPrimitiveSubscriber() { + const selected = Route.useSearch({ + select: selectShopPrimitiveSearch, + }) + + void computeSearchChecksum(selected) + return null +} + +function ShopLayout() { + return ( + <> + {shopSubscriberIds.map((id) => ( + + ))} + {shopSubscriberIds.map((id) => ( + + ))} + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/tsconfig.json b/benchmarks/client-nav/scenarios/search-params/react/tsconfig.json new file mode 100644 index 0000000000..38f89f5deb --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.ts", + "src/**/*.tsx", + "../shared.ts", + "../../../jsdom.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/search-params/react/vite.config.ts b/benchmarks/client-nav/scenarios/search-params/react/vite.config.ts new file mode 100644 index 0000000000..bddf7fef39 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/react/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import codspeedPlugin from '@codspeed/vitest-plugin' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav search-params (react)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/search-params/shared.ts b/benchmarks/client-nav/scenarios/search-params/shared.ts new file mode 100644 index 0000000000..a15bacaa74 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/shared.ts @@ -0,0 +1,1081 @@ +import type { AnyRouter } from '@tanstack/router-core' +import type { ClientNavWorkload } from '#client-nav/benchmark' +import { createDeterministicRandom } from '#client-nav/bench-utils' +import { + createClientNavLifecycle, + warnClientNavDevMode, + type Framework, + type MountTestApp, +} from '#client-nav/lifecycle' + +export interface TransientSearch { + debug?: boolean + junk?: string +} + +export interface ShopFlags { + preview: boolean + cohorts: Array + weights: Record +} + +export interface ShopSearch { + tenant: string + locale: string + flags: ShopFlags +} + +export type ShopSearchSchema = ShopSearch & TransientSearch + +export interface ProductFilters { + categories: Array + tags: Array + price: { + min: number + max: number + } + inventory: { + inStock: boolean + warehouses: Array + } + attributes: Record +} + +export interface ProductSearchValues extends TransientSearch { + page: number + pageSize: number + sort: string + view: string + includeFacets: boolean + filters: ProductFilters +} + +export type ProductsSearch = ShopSearchSchema & ProductSearchValues + +export interface DetailSearchValues extends TransientSearch { + detailTab: string + panel: string + showInventory: boolean +} + +export type DetailSearch = ProductsSearch & DetailSearchValues + +export interface CompareSlot { + id: string + priority: number + pinned: boolean +} + +export interface CompareSearchValues extends TransientSearch { + compareIds: Array + slots: Record + matrix: Record> + includeRelated: boolean + revision: number +} + +export type CompareSearch = ShopSearchSchema & CompareSearchValues + +export interface SearchNavigationSet { + full: ProductsSearch + nested: ProductsSearch + paged: ProductsSearch + detail: DetailSearchValues + compare: CompareSearch + updater: ProductsSearch + productId: string +} + +export interface ProductsLoaderDeps { + tenant: string + locale: string + page: number + pageSize: number + sort: string + filters: ProductFilters + flags: ShopFlags +} + +export interface ProductsLoaderData { + checksum: number + visibleRows: number +} + +export interface CompareLoaderDeps { + tenant: string + compareIds: Array + slots: Record + matrix: Record> + revision: number +} + +export interface CompareLoaderData { + checksum: number + itemCount: number +} + +const tenantIds = ['tenant-a', 'tenant-b', 'tenant-c', 'tenant-d'] as const +const locales = ['en-US', 'de-DE', 'fr-FR', 'ja-JP'] as const +const cohorts = ['alpha', 'beta', 'stable', 'holiday', 'vip'] as const +const categories = ['shoes', 'bags', 'jackets', 'accessories', 'sale'] as const +const tags = ['eco', 'new', 'featured', 'limited', 'bundle', 'gift'] as const +const sorts = ['relevance', 'price-asc', 'price-desc', 'rating'] as const +const views = ['grid', 'list'] as const +const productColors = ['black', 'brown', 'blue', 'white'] as const +const detailTabs = ['summary', 'reviews', 'shipping', 'bundles'] as const +const detailPanels = ['overview', 'specs', 'offers'] as const +const warehouses = ['iad', 'fra', 'hnd', 'syd', 'gru'] as const + +export const DEFAULT_FLAGS: ShopFlags = { + preview: false, + cohorts: ['stable'], + weights: { + stable: 1, + }, +} + +export const defaultProductFilters: ProductFilters = { + categories: ['shoes'], + tags: ['featured'], + price: { + min: 0, + max: 250, + }, + inventory: { + inStock: true, + warehouses: ['iad'], + }, + attributes: { + color: 'black', + rating: 4, + sustainable: true, + }, +} + +export const tenantSearchKeys: Array<'tenant'> = ['tenant'] +export const transientSearchKeys: Array = [ + 'debug', + 'junk', +] +export const defaultShopSearchStrip = { + flags: DEFAULT_FLAGS, +} satisfies Partial +export const defaultProductsSearchStrip = { + view: 'grid', +} satisfies Partial +export const defaultCompareSearchStrip = { + includeRelated: false, +} satisfies Partial + +export const shopSubscriberIds = Array.from({ length: 8 }, (_, index) => index) +export const routeSubscriberIds = Array.from({ length: 6 }, (_, index) => index) + +const NAVIGATIONS_PER_RUN = 24 +const ACTION_SEQUENCE_LENGTH = 6 +const NAVIGATION_SET_COUNT = 48 + +function pick(values: ReadonlyArray, index: number) { + return values[index % values.length]! +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function normalizeString(value: unknown, fallback: string) { + if (typeof value === 'string' && value.length > 0) { + return value + } + + return fallback +} + +function normalizeBoolean(value: unknown, fallback: boolean) { + if (typeof value === 'boolean') { + return value + } + + return fallback +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const numberValue = Number(value) + + if (Number.isFinite(numberValue) && numberValue > 0) { + return Math.trunc(numberValue) + } + + return fallback +} + +function normalizeStringArray(value: unknown, fallback: Array) { + if (Array.isArray(value)) { + const normalized = value.filter( + (item): item is string => typeof item === 'string' && item.length > 0, + ) + + if (normalized.length > 0) { + return normalized + } + } + + return fallback.slice() +} + +function normalizeNumberArray(value: unknown, fallback: Array) { + if (Array.isArray(value)) { + const normalized = value + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)) + + if (normalized.length > 0) { + return normalized + } + } + + return fallback.slice() +} + +function normalizeNumberRecord( + value: unknown, + fallback: Record, +) { + if (!isRecord(value)) { + return { ...fallback } + } + + const normalized: Record = {} + + for (const [key, item] of Object.entries(value)) { + const numberValue = Number(item) + + if (Number.isFinite(numberValue)) { + normalized[key] = numberValue + } + } + + if (Object.keys(normalized).length > 0) { + return normalized + } + + return { ...fallback } +} + +function normalizeStringNumberBooleanRecord( + value: unknown, + fallback: Record, +) { + if (!isRecord(value)) { + return { ...fallback } + } + + const normalized: Record = {} + + for (const [key, item] of Object.entries(value)) { + if ( + typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean' + ) { + normalized[key] = item + } + } + + if (Object.keys(normalized).length > 0) { + return normalized + } + + return { ...fallback } +} + +export function validateShopSearch( + search: Record, +): ShopSearchSchema { + const flags = isRecord(search.flags) ? search.flags : {} + + return { + tenant: normalizeString(search.tenant, 'tenant-a'), + locale: normalizeString(search.locale, 'en-US'), + flags: { + preview: normalizeBoolean(flags.preview, DEFAULT_FLAGS.preview), + cohorts: normalizeStringArray(flags.cohorts, DEFAULT_FLAGS.cohorts), + weights: normalizeNumberRecord(flags.weights, DEFAULT_FLAGS.weights), + }, + } +} + +export function validateProductsSearch( + search: Record, +): ProductSearchValues { + const filters = isRecord(search.filters) ? search.filters : {} + const price = isRecord(filters.price) ? filters.price : {} + const inventory = isRecord(filters.inventory) ? filters.inventory : {} + + return { + page: normalizePositiveInteger(search.page, 1), + pageSize: normalizePositiveInteger(search.pageSize, 24), + sort: normalizeString(search.sort, 'relevance'), + view: normalizeString(search.view, 'grid'), + includeFacets: normalizeBoolean(search.includeFacets, true), + filters: { + categories: normalizeStringArray( + filters.categories, + defaultProductFilters.categories, + ), + tags: normalizeStringArray(filters.tags, defaultProductFilters.tags), + price: { + min: normalizePositiveInteger( + price.min, + defaultProductFilters.price.min, + ), + max: normalizePositiveInteger( + price.max, + defaultProductFilters.price.max, + ), + }, + inventory: { + inStock: normalizeBoolean( + inventory.inStock, + defaultProductFilters.inventory.inStock, + ), + warehouses: normalizeStringArray( + inventory.warehouses, + defaultProductFilters.inventory.warehouses, + ), + }, + attributes: normalizeStringNumberBooleanRecord( + filters.attributes, + defaultProductFilters.attributes, + ), + }, + } +} + +export function validateDetailSearch( + search: Record, +): DetailSearchValues { + return { + detailTab: normalizeString(search.detailTab, 'summary'), + panel: normalizeString(search.panel, 'overview'), + showInventory: normalizeBoolean(search.showInventory, true), + } +} + +export function validateCompareSearch( + search: Record, +): CompareSearchValues { + const slots = isRecord(search.slots) ? search.slots : {} + const matrix = isRecord(search.matrix) ? search.matrix : {} + const normalizedSlots: Record = {} + const normalizedMatrix: Record> = {} + + for (const [key, value] of Object.entries(slots)) { + if (isRecord(value)) { + normalizedSlots[key] = { + id: normalizeString(value.id, key), + priority: normalizePositiveInteger(value.priority, 1), + pinned: normalizeBoolean(value.pinned, false), + } + } + } + + for (const [key, value] of Object.entries(matrix)) { + normalizedMatrix[key] = normalizeNumberArray(value, [1, 2, 3]) + } + + return { + compareIds: normalizeStringArray(search.compareIds, [ + 'sku-0001', + 'sku-0002', + ]), + slots: normalizedSlots, + matrix: normalizedMatrix, + includeRelated: normalizeBoolean(search.includeRelated, false), + revision: normalizePositiveInteger(search.revision, 1), + } +} + +function buildFlags(index: number): ShopFlags { + const cohort = pick(cohorts, index) + const nextCohort = pick(cohorts, index + 2) + + return { + preview: index % 3 === 0, + cohorts: ['stable', cohort, nextCohort], + weights: { + stable: 1, + [cohort]: (index % 5) + 1, + [nextCohort]: (index % 7) + 2, + }, + } +} + +function buildProductFilters(index: number): ProductFilters { + const random = createDeterministicRandom(0x9e3779b9 + index) + const rating = 3 + Math.floor(random() * 3) + const maxPrice = 180 + Math.floor(random() * 220) + + return { + categories: [pick(categories, index), pick(categories, index + 2)], + tags: [pick(tags, index), pick(tags, index + 3), pick(tags, index + 5)], + price: { + min: 20 + (index % 5) * 5, + max: maxPrice, + }, + inventory: { + inStock: index % 4 !== 0, + warehouses: [pick(warehouses, index), pick(warehouses, index + 3)], + }, + attributes: { + color: pick(productColors, index), + rating, + sustainable: index % 2 === 0, + width: pick(['narrow', 'regular', 'wide'], index + 1), + }, + } +} + +export function buildProductsSearch(index: number): ProductsSearch { + return { + tenant: pick(tenantIds, index), + locale: pick(locales, index + 1), + flags: buildFlags(index), + page: (index % 9) + 1, + pageSize: pick([12, 24, 36, 48], index), + sort: pick(sorts, index + 2), + view: pick(views, index), + includeFacets: index % 5 !== 0, + filters: buildProductFilters(index), + debug: index % 2 === 0, + junk: `transient-${index}`, + } +} + +export function buildCompareSearch(index: number): CompareSearch { + const base = buildProductsSearch(index + 11) + const compareIds = Array.from( + { length: 24 }, + (_, itemIndex) => + `sku-${String(index * 31 + itemIndex + 1).padStart(4, '0')}`, + ) + const slots: Record = {} + const matrix: Record> = {} + + compareIds.slice(0, 12).forEach((id, itemIndex) => { + slots[`slot-${itemIndex}`] = { + id, + priority: (itemIndex % 5) + 1, + pinned: itemIndex % 3 === 0, + } + }) + + for (let rowIndex = 0; rowIndex < 8; rowIndex++) { + matrix[`metric-${rowIndex}`] = Array.from( + { length: 6 }, + (_, columnIndex) => index * 17 + rowIndex * 5 + columnIndex, + ) + } + + return { + tenant: base.tenant, + locale: base.locale, + flags: base.flags, + compareIds, + slots, + matrix, + includeRelated: index % 2 === 0, + revision: index + 1, + debug: true, + junk: `compare-junk-${index}`, + } +} + +function buildNavigationSet(index: number): SearchNavigationSet { + const full = buildProductsSearch(index * 3) + const nestedColors = productColors.filter( + (color) => color !== full.filters.attributes.color, + ) + const nested: ProductsSearch = { + ...full, + filters: { + ...full.filters, + attributes: { + ...full.filters.attributes, + color: pick(nestedColors, index), + }, + }, + } + const paged: ProductsSearch = { + ...nested, + page: nested.page + 1, + } + + return { + full, + nested, + paged, + detail: { + detailTab: pick(detailTabs, index), + panel: pick(detailPanels, index + 1), + showInventory: index % 2 === 0, + }, + compare: buildCompareSearch(index), + updater: buildProductsSearch(index * 3 + 1), + productId: `sku-${String(index + 1).padStart(4, '0')}`, + } +} + +export const navigationSets = Array.from( + { length: NAVIGATION_SET_COUNT }, + (_, index) => buildNavigationSet(index), +) + +export const initialProductsSearch: ProductsSearch = { + ...buildProductsSearch(97), + debug: undefined, + junk: undefined, +} + +function parseJsonValue(value: string) { + try { + return JSON.parse(value) as unknown + } catch (_error) { + return value + } +} + +export function parseJsonSearch(searchStr: string) { + const params = new URLSearchParams( + searchStr.startsWith('?') ? searchStr.slice(1) : searchStr, + ) + const search: Record = {} + + params.forEach((value, key) => { + search[key] = parseJsonValue(value) + }) + + return search +} + +export function stringifyJsonSearch(search: object) { + const searchRecord = search as Record + const params = new URLSearchParams() + + for (const key of Object.keys(searchRecord).sort()) { + const value = searchRecord[key] + + if (value !== undefined) { + params.set(key, JSON.stringify(value)) + } + } + + const value = params.toString() + + if (value.length === 0) { + return '' + } + + return `?${value}` +} + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => stableValue(item)) + } + + if (isRecord(value)) { + const output: Record = {} + + for (const key of Object.keys(value).sort()) { + const item = value[key] + + if (item !== undefined) { + output[key] = stableValue(item) + } + } + + return output + } + + return value +} + +export function stableStringify(value: unknown) { + return JSON.stringify(stableValue(value)) +} + +export function computeSearchChecksum(value: unknown) { + const serialized = stableStringify(value) + let seed = serialized.length | 0 + + for (let index = 0; index < serialized.length; index++) { + seed = (seed * 33 + serialized.charCodeAt(index)) >>> 0 + } + + for (let index = 0; index < 36; index++) { + seed = (seed * 1664525 + 1013904223 + index) >>> 0 + } + + return seed +} + +export function selectShopSearch(search: unknown) { + const typedSearch = search as ShopSearchSchema + + return { + tenant: typedSearch.tenant, + locale: typedSearch.locale, + flags: typedSearch.flags, + } +} + +export function selectShopPrimitiveSearch(search: unknown) { + const typedSearch = search as ShopSearchSchema + + return `${typedSearch.tenant}:${typedSearch.locale}` +} + +export function selectProductsSearch(search: unknown) { + const typedSearch = search as ProductsSearch + + return { + filters: typedSearch.filters, + flags: typedSearch.flags, + } +} + +export function selectProductsPrimitiveSearch(search: unknown) { + const typedSearch = search as ProductsSearch + + return { + page: typedSearch.page, + pageSize: typedSearch.pageSize, + sort: typedSearch.sort, + } +} + +export function createProductsLoaderDeps({ + search, +}: { + search: ProductsSearch +}) { + return { + tenant: search.tenant, + locale: search.locale, + page: search.page, + pageSize: search.pageSize, + sort: search.sort, + filters: search.filters, + flags: search.flags, + } satisfies ProductsLoaderDeps +} + +export function createProductsLoaderData({ + deps, +}: { + deps: ProductsLoaderDeps +}) { + return { + checksum: computeSearchChecksum(deps), + visibleRows: deps.page * deps.pageSize, + } satisfies ProductsLoaderData +} + +export function selectProductsLoaderDeps(deps: unknown) { + const typedDeps = deps as ProductsLoaderDeps + + return { + page: typedDeps.page, + filters: typedDeps.filters, + flags: typedDeps.flags, + } +} + +export function selectProductsLoaderData(data: unknown) { + const typedData = data as ProductsLoaderData + + return { + checksum: typedData.checksum, + visibleRows: typedData.visibleRows, + } +} + +export function formatProductsMarker( + search: ProductsSearch, + loaderData: ProductsLoaderData, +) { + return [ + 'products', + search.tenant, + search.page, + search.filters.price.max, + search.filters.attributes.color, + loaderData.checksum, + ].join(':') +} + +export function selectDetailSearch(search: unknown) { + const typedSearch = search as DetailSearch + + return { + tenant: typedSearch.tenant, + filters: typedSearch.filters, + detailTab: typedSearch.detailTab, + panel: typedSearch.panel, + } +} + +export function formatDetailMarker(productId: string, search: DetailSearch) { + return [ + 'detail', + productId, + search.tenant, + search.detailTab, + search.panel, + ].join(':') +} + +export function selectCompareSearch(search: unknown) { + const typedSearch = search as CompareSearch + + return { + tenant: typedSearch.tenant, + compareIds: typedSearch.compareIds, + slots: typedSearch.slots, + matrix: typedSearch.matrix, + } +} + +export function createCompareLoaderDeps({ search }: { search: CompareSearch }) { + return { + tenant: search.tenant, + compareIds: search.compareIds, + slots: search.slots, + matrix: search.matrix, + revision: search.revision, + } satisfies CompareLoaderDeps +} + +export function createCompareLoaderData({ deps }: { deps: CompareLoaderDeps }) { + return { + checksum: computeSearchChecksum(deps), + itemCount: deps.compareIds.length, + } satisfies CompareLoaderData +} + +export function selectCompareLoaderDeps(deps: unknown) { + const typedDeps = deps as CompareLoaderDeps + + return { + compareIds: typedDeps.compareIds, + slots: typedDeps.slots, + revision: typedDeps.revision, + } +} + +export function formatCompareMarker( + search: CompareSearch, + loaderData: CompareLoaderData, +) { + return [ + 'compare', + search.tenant, + search.compareIds.length, + loaderData.itemCount, + loaderData.checksum, + ].join(':') +} + +export function assertEqual(actual: T, expected: T, label: string) { + if (actual !== expected) { + throw new Error( + `${label}: expected ${String(expected)}, received ${String(actual)}`, + ) + } +} + +export function assertDeepEqual( + actual: unknown, + expected: unknown, + label: string, +) { + const actualValue = stableStringify(actual) + const expectedValue = stableStringify(expected) + + if (actualValue !== expectedValue) { + throw new Error( + `${label}: expected ${expectedValue}, received ${actualValue}`, + ) + } +} + +export function assertNoTransientSearchKeys( + search: Record, + label: string, +) { + if ('debug' in search || 'junk' in search) { + throw new Error(`${label}: transient search keys survived`) + } +} + +function assertMarker( + container: HTMLElement, + testId: string, + expectedText: string, +) { + const marker = container.querySelector(`[data-testid="${testId}"]`) + + if (!(marker instanceof HTMLElement)) { + throw new Error(`Missing ${testId} marker`) + } + + const text = marker.textContent ?? '' + + if (!text.includes(expectedText)) { + throw new Error( + `${testId}: expected text containing ${expectedText}, got ${text}`, + ) + } +} + +function assertCustomSearchRoundTrip() { + const complexSearch = navigationSets[3]!.compare + const roundTripped = parseJsonSearch(stringifyJsonSearch(complexSearch)) + + assertDeepEqual( + roundTripped, + complexSearch, + 'custom search serializer round trip', + ) +} + +export function createSearchParamsWorkload( + framework: Framework, + mountTestApp: MountTestApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp, timeoutMs: 4_000 }) + let stepIndex = 0 + + function patchGlobalScrollTo() { + const globalObject = globalThis as unknown as { + scrollTo?: (...args: Array) => void + } + const windowObject = window as unknown as { + scrollTo?: (...args: Array) => void + } + const hadScrollTo = Object.prototype.hasOwnProperty.call( + globalObject, + 'scrollTo', + ) + const previousScrollTo = globalObject.scrollTo + + if (typeof previousScrollTo === 'function') { + return + } + + globalObject.scrollTo = (...args: Array) => { + if (typeof windowObject.scrollTo === 'function') { + windowObject.scrollTo(...args) + } + } + + lifecycle.addCleanup(() => { + if (hadScrollTo) { + globalObject.scrollTo = previousScrollTo + } else { + delete globalObject.scrollTo + } + }) + } + + async function before() { + stepIndex = 0 + await lifecycle.before() + patchGlobalScrollTo() + await lifecycle.waitForLink('products-strip-link') + } + + async function sanity() { + assertCustomSearchRoundTrip() + await lifecycle.before() + patchGlobalScrollTo() + + try { + await lifecycle.waitForLink('products-strip-link') + const productsLink = await lifecycle.waitForLink('products-strip-link') + const href = productsLink.getAttribute('href') + + if (!href) { + throw new Error('products-strip-link did not generate an href') + } + + if (href.includes('debug') || href.includes('junk')) { + throw new Error(`products-strip-link retained transient keys: ${href}`) + } + + const linkSearch = parseJsonSearch( + new URL(href, 'https://router.test').search, + ) + assertEqual( + linkSearch.tenant, + buildProductsSearch(41).tenant, + 'link tenant', + ) + assertNoTransientSearchKeys(linkSearch, 'products link search') + + const set = navigationSets[2]! + await lifecycle.navigate( + { + to: '/shop/products', + search: set.full, + replace: true, + }, + { label: 'sanity products navigation' }, + ) + + const productsSearch = lifecycle.getRouter().state.location + .search as Record + assertNoTransientSearchKeys(productsSearch, 'products navigation search') + assertEqual(productsSearch.tenant, set.full.tenant, 'products tenant') + assertEqual(productsSearch.page, set.full.page, 'products page') + assertMarker(lifecycle.getContainer(), 'products-marker', 'products:') + + await lifecycle.navigate( + { + to: '/shop/products/$productId', + params: { productId: set.productId }, + replace: true, + search: (prev: Record) => ({ + ...prev, + ...set.detail, + }), + }, + { label: 'sanity detail navigation' }, + ) + assertMarker(lifecycle.getContainer(), 'detail-marker', set.productId) + + await lifecycle.navigate( + { + to: '/shop/compare', + search: set.compare, + replace: true, + }, + { label: 'sanity compare navigation' }, + ) + + const compareSearch = lifecycle.getRouter().state.location + .search as Record + assertNoTransientSearchKeys(compareSearch, 'compare navigation search') + assertEqual( + (compareSearch.compareIds as Array).length, + set.compare.compareIds.length, + 'compare ids length', + ) + assertMarker(lifecycle.getContainer(), 'compare-marker', 'compare:') + } finally { + await lifecycle.after() + } + } + + async function runStep() { + const set = + navigationSets[ + Math.floor(stepIndex / ACTION_SEQUENCE_LENGTH) % navigationSets.length + ]! + const actionIndex = stepIndex % ACTION_SEQUENCE_LENGTH + stepIndex += 1 + + if (actionIndex === 0) { + await lifecycle.navigate( + { + to: '/shop/products', + search: set.full, + replace: true, + }, + { label: 'run products full navigation' }, + ) + return + } + + if (actionIndex === 1) { + await lifecycle.navigate( + { + to: '/shop/products', + search: set.nested, + replace: true, + }, + { label: 'run products nested navigation' }, + ) + return + } + + if (actionIndex === 2) { + await lifecycle.navigate( + { + to: '/shop/products', + search: set.paged, + replace: true, + }, + { label: 'run products pagination navigation' }, + ) + return + } + + if (actionIndex === 3) { + await lifecycle.navigate( + { + to: '/shop/products/$productId', + params: { productId: set.productId }, + replace: true, + search: (prev: Record) => ({ + ...prev, + ...set.detail, + }), + }, + { label: 'run product detail navigation' }, + ) + return + } + + if (actionIndex === 4) { + await lifecycle.navigate( + { + to: '/shop/compare', + search: set.compare, + replace: true, + }, + { label: 'run compare navigation' }, + ) + return + } + + await lifecycle.navigate( + { + to: '/shop/products', + replace: true, + search: (prev: Record) => ({ + ...set.updater, + tenant: + typeof prev.tenant === 'string' ? prev.tenant : set.updater.tenant, + }), + }, + { label: 'run products updater navigation' }, + ) + } + + async function run() { + for (let index = 0; index < NAVIGATIONS_PER_RUN; index++) { + await runStep() + } + } + + return { + name: `client search params loop (${framework})`, + before, + run, + sanity, + after: lifecycle.after, + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/project.json b/benchmarks/client-nav/scenarios/search-params/solid/project.json new file mode 100644 index 0000000000..79c080b213 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-search-params-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/setup.ts b/benchmarks/client-nav/scenarios/search-params/solid/setup.ts new file mode 100644 index 0000000000..088ab9f10a --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createSearchParamsWorkload } from '../shared' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createSearchParamsWorkload('solid', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/search-params/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/search-params/solid/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/search-params/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/search-params/solid/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/app.tsx b/benchmarks/client-nav/scenarios/search-params/solid/src/app.tsx new file mode 100644 index 0000000000..4b48cdc069 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/app.tsx @@ -0,0 +1,21 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { getRouter } from './router' + +export function mountTestApp(container: HTMLDivElement) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/perf.tsx b/benchmarks/client-nav/scenarios/search-params/solid/src/perf.tsx new file mode 100644 index 0000000000..03fcce9472 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/perf.tsx @@ -0,0 +1,10 @@ +import { createRenderEffect } from 'solid-js' +import { computeSearchChecksum } from '../../shared' + +export function PerfValue(props: { value: () => unknown }) { + createRenderEffect(() => { + void computeSearchChecksum(props.value()) + }) + + return null +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/search-params/solid/src/routeTree.gen.ts new file mode 100644 index 0000000000..bc2d4354fb --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/routeTree.gen.ts @@ -0,0 +1,144 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ShopRouteImport } from './routes/shop' +import { Route as ShopCompareRouteImport } from './routes/shop.compare' +import { Route as ShopProductsRouteImport } from './routes/shop.products' +import { Route as ShopProductsProductIdRouteImport } from './routes/shop.products.$productId' + +const ShopRoute = ShopRouteImport.update({ + id: '/shop', + path: '/shop', + getParentRoute: () => rootRouteImport, +} as any) +const ShopCompareRoute = ShopCompareRouteImport.update({ + id: '/compare', + path: '/compare', + getParentRoute: () => ShopRoute, +} as any) +const ShopProductsRoute = ShopProductsRouteImport.update({ + id: '/products', + path: '/products', + getParentRoute: () => ShopRoute, +} as any) +const ShopProductsProductIdRoute = ShopProductsProductIdRouteImport.update({ + id: '/$productId', + path: '/$productId', + getParentRoute: () => ShopProductsRoute, +} as any) + +export interface FileRoutesByFullPath { + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRoutesByTo { + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + fileRoutesByTo: FileRoutesByTo + to: + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + id: + | '__root__' + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + ShopRoute: typeof ShopRouteWithChildren +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/shop': { + id: '/shop' + path: '/shop' + fullPath: '/shop' + preLoaderRoute: typeof ShopRouteImport + parentRoute: typeof rootRouteImport + } + '/shop/compare': { + id: '/shop/compare' + path: '/compare' + fullPath: '/shop/compare' + preLoaderRoute: typeof ShopCompareRouteImport + parentRoute: typeof ShopRoute + } + '/shop/products': { + id: '/shop/products' + path: '/products' + fullPath: '/shop/products' + preLoaderRoute: typeof ShopProductsRouteImport + parentRoute: typeof ShopRoute + } + '/shop/products/$productId': { + id: '/shop/products/$productId' + path: '/$productId' + fullPath: '/shop/products/$productId' + preLoaderRoute: typeof ShopProductsProductIdRouteImport + parentRoute: typeof ShopProductsRoute + } + } +} + +interface ShopProductsRouteChildren { + ShopProductsProductIdRoute: typeof ShopProductsProductIdRoute +} + +const ShopProductsRouteChildren: ShopProductsRouteChildren = { + ShopProductsProductIdRoute: ShopProductsProductIdRoute, +} + +const ShopProductsRouteWithChildren = ShopProductsRoute._addFileChildren( + ShopProductsRouteChildren, +) + +interface ShopRouteChildren { + ShopProductsRoute: typeof ShopProductsRouteWithChildren + ShopCompareRoute: typeof ShopCompareRoute +} + +const ShopRouteChildren: ShopRouteChildren = { + ShopProductsRoute: ShopProductsRouteWithChildren, + ShopCompareRoute: ShopCompareRoute, +} + +const ShopRouteWithChildren = ShopRoute._addFileChildren(ShopRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + ShopRoute: ShopRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/router.tsx b/benchmarks/client-nav/scenarios/search-params/solid/src/router.tsx new file mode 100644 index 0000000000..d4a002f499 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/router.tsx @@ -0,0 +1,27 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { + initialProductsSearch, + parseJsonSearch, + stringifyJsonSearch, +} from '../../shared' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [ + `/shop/products${stringifyJsonSearch(initialProductsSearch)}`, + ], + }), + parseSearch: parseJsonSearch, + stringifySearch: stringifyJsonSearch, + search: { strict: true }, + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..cb8d5a688d --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.compare.tsx b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.compare.tsx new file mode 100644 index 0000000000..33735441b0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.compare.tsx @@ -0,0 +1,69 @@ +import { For } from 'solid-js' +import { + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/solid-router' +import { + createCompareLoaderData, + createCompareLoaderDeps, + defaultCompareSearchStrip, + formatCompareMarker, + routeSubscriberIds, + selectCompareLoaderDeps, + selectCompareSearch, + tenantSearchKeys, + transientSearchKeys, + validateCompareSearch, + type CompareSearch, +} from '../../../shared' +import { PerfValue } from '../perf' + +export const Route = createFileRoute('/shop/compare')({ + validateSearch: validateCompareSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultCompareSearchStrip), + ], + }, + loaderDeps: createCompareLoaderDeps, + loader: createCompareLoaderData, + staleTime: 60_000, + gcTime: 60_000, + component: ComparePage, +}) + +function CompareSearchSubscriber() { + const selected = Route.useSearch({ + select: selectCompareSearch, + }) + + return selected()} /> +} + +function CompareLoaderDepsSubscriber() { + const loaderDeps = Route.useLoaderDeps({ + select: selectCompareLoaderDeps, + }) + + return loaderDeps()} /> +} + +function ComparePage() { + const search = Route.useSearch() + const loaderData = Route.useLoaderData() + + return ( + <> + {() => } + + {() => } + +
+ {formatCompareMarker(search(), loaderData())} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.products.$productId.tsx b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.products.$productId.tsx new file mode 100644 index 0000000000..c3448090ea --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.products.$productId.tsx @@ -0,0 +1,41 @@ +import { For } from 'solid-js' +import { createFileRoute, stripSearchParams } from '@tanstack/solid-router' +import { + formatDetailMarker, + routeSubscriberIds, + selectDetailSearch, + transientSearchKeys, + validateDetailSearch, + type DetailSearch, +} from '../../../shared' +import { PerfValue } from '../perf' + +export const Route = createFileRoute('/shop/products/$productId')({ + validateSearch: validateDetailSearch, + search: { + middlewares: [stripSearchParams(transientSearchKeys)], + }, + component: ProductDetailPage, +}) + +function DetailSearchSubscriber() { + const selected = Route.useSearch({ + select: selectDetailSearch, + }) + + return selected()} /> +} + +function ProductDetailPage() { + const params = Route.useParams() + const search = Route.useSearch() + + return ( + <> + {() => } +
+ {formatDetailMarker(params().productId, search())} +
+ + ) +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.products.tsx b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.products.tsx new file mode 100644 index 0000000000..98ed866cb3 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.products.tsx @@ -0,0 +1,95 @@ +import { For } from 'solid-js' +import { + Outlet, + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/solid-router' +import { + createProductsLoaderData, + createProductsLoaderDeps, + defaultProductsSearchStrip, + formatProductsMarker, + routeSubscriberIds, + selectProductsLoaderData, + selectProductsLoaderDeps, + selectProductsPrimitiveSearch, + selectProductsSearch, + tenantSearchKeys, + transientSearchKeys, + validateProductsSearch, + type ProductsSearch, +} from '../../../shared' +import { PerfValue } from '../perf' + +export const Route = createFileRoute('/shop/products')({ + validateSearch: validateProductsSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultProductsSearchStrip), + ], + }, + loaderDeps: createProductsLoaderDeps, + loader: createProductsLoaderData, + staleTime: 60_000, + gcTime: 60_000, + component: ProductsPage, +}) + +function ProductsSearchSubscriber() { + const selected = Route.useSearch({ + select: selectProductsSearch, + }) + + return selected()} /> +} + +function ProductsPrimitiveSubscriber() { + const selected = Route.useSearch({ + select: selectProductsPrimitiveSearch, + }) + + return selected()} /> +} + +function ProductsLoaderDepsSubscriber() { + const loaderDeps = Route.useLoaderDeps({ + select: selectProductsLoaderDeps, + }) + + return loaderDeps()} /> +} + +function ProductsLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: selectProductsLoaderData, + }) + + return loaderData()} /> +} + +function ProductsPage() { + const search = Route.useSearch() + const loaderData = Route.useLoaderData() + + return ( + <> + {() => } + + {() => } + + + {() => } + + + {() => } + +
+ {formatProductsMarker(search(), loaderData())} +
+ + + ) +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.tsx b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.tsx new file mode 100644 index 0000000000..6d947c7bba --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/src/routes/shop.tsx @@ -0,0 +1,79 @@ +import { For } from 'solid-js' +import { + Link, + Outlet, + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/solid-router' +import { + buildCompareSearch, + buildProductsSearch, + defaultShopSearchStrip, + selectShopPrimitiveSearch, + selectShopSearch, + shopSubscriberIds, + tenantSearchKeys, + transientSearchKeys, + validateShopSearch, + type ShopSearchSchema, +} from '../../../shared' +import { PerfValue } from '../perf' + +export const Route = createFileRoute('/shop')({ + validateSearch: validateShopSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultShopSearchStrip), + ], + }, + component: ShopLayout, +}) + +function ShopSearchSubscriber() { + const selected = Route.useSearch({ + select: selectShopSearch, + }) + + return selected()} /> +} + +function ShopPrimitiveSubscriber() { + const selected = Route.useSearch({ + select: selectShopPrimitiveSearch, + }) + + return selected()} /> +} + +function ShopLayout() { + return ( + <> + {() => } + {() => } + + + + ) +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/tsconfig.json b/benchmarks/client-nav/scenarios/search-params/solid/tsconfig.json new file mode 100644 index 0000000000..92146640b9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.ts", + "src/**/*.tsx", + "../shared.ts", + "../../../jsdom.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/search-params/solid/vite.config.ts b/benchmarks/client-nav/scenarios/search-params/solid/vite.config.ts new file mode 100644 index 0000000000..2f067a20b7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/solid/vite.config.ts @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import solid from 'vite-plugin-solid' +import codspeedPlugin from '@codspeed/vitest-plugin' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav search-params (solid)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/search-params/vue/project.json b/benchmarks/client-nav/scenarios/search-params/vue/project.json new file mode 100644 index 0000000000..48589de377 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-search-params-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/vue/setup.ts b/benchmarks/client-nav/scenarios/search-params/vue/setup.ts new file mode 100644 index 0000000000..11c9a407c9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/setup.ts @@ -0,0 +1,9 @@ +import type * as App from './src/app' +import { createSearchParamsWorkload } from '../shared' + +const appModulePath = './dist/app.js' +const { mountTestApp } = (await import( + /* @vite-ignore */ appModulePath +)) as typeof App + +export const workload = createSearchParamsWorkload('vue', mountTestApp) diff --git a/benchmarks/client-nav/scenarios/search-params/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/search-params/vue/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/search-params/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/search-params/vue/speed.flame.ts new file mode 100644 index 0000000000..1e36e1a7cf --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/speed.flame.ts @@ -0,0 +1,18 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/search-params/vue/src/app.tsx b/benchmarks/client-nav/scenarios/search-params/vue/src/app.tsx new file mode 100644 index 0000000000..2812f538d5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/src/app.tsx @@ -0,0 +1,25 @@ +import * as Vue from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { getRouter } from './router' + +export function mountTestApp(container: HTMLDivElement) { + const router = getRouter() + const app = Vue.createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/vue/src/routeTree.gen.ts b/benchmarks/client-nav/scenarios/search-params/vue/src/routeTree.gen.ts new file mode 100644 index 0000000000..d067605414 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/src/routeTree.gen.ts @@ -0,0 +1,144 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ShopRouteImport } from './routes/shop' +import { Route as ShopCompareRouteImport } from './routes/shop.compare' +import { Route as ShopProductsRouteImport } from './routes/shop.products' +import { Route as ShopProductsProductIdRouteImport } from './routes/shop.products.$productId' + +const ShopRoute = ShopRouteImport.update({ + id: '/shop', + path: '/shop', + getParentRoute: () => rootRouteImport, +} as any) +const ShopCompareRoute = ShopCompareRouteImport.update({ + id: '/compare', + path: '/compare', + getParentRoute: () => ShopRoute, +} as any) +const ShopProductsRoute = ShopProductsRouteImport.update({ + id: '/products', + path: '/products', + getParentRoute: () => ShopRoute, +} as any) +const ShopProductsProductIdRoute = ShopProductsProductIdRouteImport.update({ + id: '/$productId', + path: '/$productId', + getParentRoute: () => ShopProductsRoute, +} as any) + +export interface FileRoutesByFullPath { + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRoutesByTo { + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/shop': typeof ShopRouteWithChildren + '/shop/compare': typeof ShopCompareRoute + '/shop/products': typeof ShopProductsRouteWithChildren + '/shop/products/$productId': typeof ShopProductsProductIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + fileRoutesByTo: FileRoutesByTo + to: + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + id: + | '__root__' + | '/shop' + | '/shop/compare' + | '/shop/products' + | '/shop/products/$productId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + ShopRoute: typeof ShopRouteWithChildren +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/shop': { + id: '/shop' + path: '/shop' + fullPath: '/shop' + preLoaderRoute: typeof ShopRouteImport + parentRoute: typeof rootRouteImport + } + '/shop/compare': { + id: '/shop/compare' + path: '/compare' + fullPath: '/shop/compare' + preLoaderRoute: typeof ShopCompareRouteImport + parentRoute: typeof ShopRoute + } + '/shop/products': { + id: '/shop/products' + path: '/products' + fullPath: '/shop/products' + preLoaderRoute: typeof ShopProductsRouteImport + parentRoute: typeof ShopRoute + } + '/shop/products/$productId': { + id: '/shop/products/$productId' + path: '/$productId' + fullPath: '/shop/products/$productId' + preLoaderRoute: typeof ShopProductsProductIdRouteImport + parentRoute: typeof ShopProductsRoute + } + } +} + +interface ShopProductsRouteChildren { + ShopProductsProductIdRoute: typeof ShopProductsProductIdRoute +} + +const ShopProductsRouteChildren: ShopProductsRouteChildren = { + ShopProductsProductIdRoute: ShopProductsProductIdRoute, +} + +const ShopProductsRouteWithChildren = ShopProductsRoute._addFileChildren( + ShopProductsRouteChildren, +) + +interface ShopRouteChildren { + ShopProductsRoute: typeof ShopProductsRouteWithChildren + ShopCompareRoute: typeof ShopCompareRoute +} + +const ShopRouteChildren: ShopRouteChildren = { + ShopProductsRoute: ShopProductsRouteWithChildren, + ShopCompareRoute: ShopCompareRoute, +} + +const ShopRouteWithChildren = ShopRoute._addFileChildren(ShopRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + ShopRoute: ShopRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/scenarios/search-params/vue/src/router.tsx b/benchmarks/client-nav/scenarios/search-params/vue/src/router.tsx new file mode 100644 index 0000000000..126d1ec053 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/src/router.tsx @@ -0,0 +1,27 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { + initialProductsSearch, + parseJsonSearch, + stringifyJsonSearch, +} from '../../shared' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [ + `/shop/products${stringifyJsonSearch(initialProductsSearch)}`, + ], + }), + parseSearch: parseJsonSearch, + stringifySearch: stringifyJsonSearch, + search: { strict: true }, + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/search-params/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..91296e6f84 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.compare.tsx b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.compare.tsx new file mode 100644 index 0000000000..fc8438f2d9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.compare.tsx @@ -0,0 +1,83 @@ +import * as Vue from 'vue' +import { + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/vue-router' +import { + createCompareLoaderData, + createCompareLoaderDeps, + computeSearchChecksum, + defaultCompareSearchStrip, + formatCompareMarker, + routeSubscriberIds, + selectCompareLoaderDeps, + selectCompareSearch, + tenantSearchKeys, + transientSearchKeys, + validateCompareSearch, + type CompareSearch, +} from '../../../shared' + +const CompareSearchSubscriber = Vue.defineComponent({ + setup() { + const selected = Route.useSearch({ + select: selectCompareSearch, + }) + + return () => { + void computeSearchChecksum(selected.value) + return null + } + }, +}) + +const CompareLoaderDepsSubscriber = Vue.defineComponent({ + setup() { + const loaderDeps = Route.useLoaderDeps({ + select: selectCompareLoaderDeps, + }) + + return () => { + void computeSearchChecksum(loaderDeps.value) + return null + } + }, +}) + +const ComparePage = Vue.defineComponent({ + setup() { + const search = Route.useSearch() + const loaderData = Route.useLoaderData() + + return () => ( + <> + {routeSubscriberIds.map((id) => ( + + ))} + {routeSubscriberIds.map((id) => ( + + ))} +
+ {formatCompareMarker(search.value, loaderData.value)} +
+ + ) + }, +}) + +export const Route = createFileRoute('/shop/compare')({ + validateSearch: validateCompareSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultCompareSearchStrip), + ], + }, + loaderDeps: createCompareLoaderDeps, + loader: createCompareLoaderData, + staleTime: 60_000, + gcTime: 60_000, + component: ComparePage, +}) diff --git a/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.products.$productId.tsx b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.products.$productId.tsx new file mode 100644 index 0000000000..3e01a58515 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.products.$productId.tsx @@ -0,0 +1,50 @@ +import * as Vue from 'vue' +import { createFileRoute, stripSearchParams } from '@tanstack/vue-router' +import { + computeSearchChecksum, + formatDetailMarker, + routeSubscriberIds, + selectDetailSearch, + transientSearchKeys, + validateDetailSearch, + type DetailSearch, +} from '../../../shared' + +const DetailSearchSubscriber = Vue.defineComponent({ + setup() { + const selected = Route.useSearch({ + select: selectDetailSearch, + }) + + return () => { + void computeSearchChecksum(selected.value) + return null + } + }, +}) + +const ProductDetailPage = Vue.defineComponent({ + setup() { + const params = Route.useParams() + const search = Route.useSearch() + + return () => ( + <> + {routeSubscriberIds.map((id) => ( + + ))} +
+ {formatDetailMarker(params.value.productId, search.value)} +
+ + ) + }, +}) + +export const Route = createFileRoute('/shop/products/$productId')({ + validateSearch: validateDetailSearch, + search: { + middlewares: [stripSearchParams(transientSearchKeys)], + }, + component: ProductDetailPage, +}) diff --git a/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.products.tsx b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.products.tsx new file mode 100644 index 0000000000..a87ca5ddab --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.products.tsx @@ -0,0 +1,119 @@ +import * as Vue from 'vue' +import { + Outlet, + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/vue-router' +import { + createProductsLoaderData, + createProductsLoaderDeps, + computeSearchChecksum, + defaultProductsSearchStrip, + formatProductsMarker, + routeSubscriberIds, + selectProductsLoaderData, + selectProductsLoaderDeps, + selectProductsPrimitiveSearch, + selectProductsSearch, + tenantSearchKeys, + transientSearchKeys, + validateProductsSearch, + type ProductsSearch, +} from '../../../shared' + +const ProductsSearchSubscriber = Vue.defineComponent({ + setup() { + const selected = Route.useSearch({ + select: selectProductsSearch, + }) + + return () => { + void computeSearchChecksum(selected.value) + return null + } + }, +}) + +const ProductsPrimitiveSubscriber = Vue.defineComponent({ + setup() { + const selected = Route.useSearch({ + select: selectProductsPrimitiveSearch, + }) + + return () => { + void computeSearchChecksum(selected.value) + return null + } + }, +}) + +const ProductsLoaderDepsSubscriber = Vue.defineComponent({ + setup() { + const loaderDeps = Route.useLoaderDeps({ + select: selectProductsLoaderDeps, + }) + + return () => { + void computeSearchChecksum(loaderDeps.value) + return null + } + }, +}) + +const ProductsLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: selectProductsLoaderData, + }) + + return () => { + void computeSearchChecksum(loaderData.value) + return null + } + }, +}) + +const ProductsPage = Vue.defineComponent({ + setup() { + const search = Route.useSearch() + const loaderData = Route.useLoaderData() + + return () => ( + <> + {routeSubscriberIds.map((id) => ( + + ))} + {routeSubscriberIds.map((id) => ( + + ))} + {routeSubscriberIds.map((id) => ( + + ))} + {routeSubscriberIds.map((id) => ( + + ))} +
+ {formatProductsMarker(search.value, loaderData.value)} +
+ + + ) + }, +}) + +export const Route = createFileRoute('/shop/products')({ + validateSearch: validateProductsSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultProductsSearchStrip), + ], + }, + loaderDeps: createProductsLoaderDeps, + loader: createProductsLoaderData, + staleTime: 60_000, + gcTime: 60_000, + component: ProductsPage, +}) diff --git a/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.tsx b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.tsx new file mode 100644 index 0000000000..75cb8541d7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/src/routes/shop.tsx @@ -0,0 +1,95 @@ +import * as Vue from 'vue' +import { + Link, + Outlet, + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/vue-router' +import { + buildCompareSearch, + buildProductsSearch, + computeSearchChecksum, + defaultShopSearchStrip, + selectShopPrimitiveSearch, + selectShopSearch, + shopSubscriberIds, + tenantSearchKeys, + transientSearchKeys, + validateShopSearch, + type ShopSearchSchema, +} from '../../../shared' + +const ShopSearchSubscriber = Vue.defineComponent({ + setup() { + const selected = Route.useSearch({ + select: selectShopSearch, + }) + + return () => { + void computeSearchChecksum(selected.value) + return null + } + }, +}) + +const ShopPrimitiveSubscriber = Vue.defineComponent({ + setup() { + const selected = Route.useSearch({ + select: selectShopPrimitiveSearch, + }) + + return () => { + void computeSearchChecksum(selected.value) + return null + } + }, +}) + +const ShopLayout = Vue.defineComponent({ + setup() { + return () => ( + <> + {shopSubscriberIds.map((id) => ( + + ))} + {shopSubscriberIds.map((id) => ( + + ))} + + + + ) + }, +}) + +export const Route = createFileRoute('/shop')({ + validateSearch: validateShopSearch, + search: { + middlewares: [ + retainSearchParams(tenantSearchKeys), + stripSearchParams(transientSearchKeys), + stripSearchParams(defaultShopSearchStrip), + ], + }, + component: ShopLayout, +}) diff --git a/benchmarks/client-nav/scenarios/search-params/vue/tsconfig.json b/benchmarks/client-nav/scenarios/search-params/vue/tsconfig.json new file mode 100644 index 0000000000..e09cf487bf --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.ts", + "src/**/*.tsx", + "../shared.ts", + "../../../jsdom.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/search-params/vue/vite.config.ts b/benchmarks/client-nav/scenarios/search-params/vue/vite.config.ts new file mode 100644 index 0000000000..40133e3643 --- /dev/null +++ b/benchmarks/client-nav/scenarios/search-params/vue/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import codspeedPlugin from '@codspeed/vitest-plugin' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav search-params (vue)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/project.json b/benchmarks/client-nav/scenarios/subscribers-selectors/react/project.json new file mode 100644 index 0000000000..c88bb9efc2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-subscribers-selectors-react", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/react-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/setup.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/react/setup.ts new file mode 100644 index 0000000000..fb66263b40 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createSubscribersSelectorsWorkload } from '../workload.ts' + +const appModulePath = './dist/app.js' +const app = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createSubscribersSelectorsWorkload('react', app) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/speed.bench.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/react/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/speed.flame.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/react/speed.flame.ts new file mode 100644 index 0000000000..c96317c25b --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/speed.flame.ts @@ -0,0 +1,19 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/app.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/app.tsx new file mode 100644 index 0000000000..c669246a64 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/app.tsx @@ -0,0 +1,29 @@ +import { RouterProvider } from '@tanstack/react-router' +import { createRoot } from 'react-dom/client' +import { getRouter } from './router' + +export { + getSubscriberCounts, + resetSubscriberCounts, + setSubscriberCountersEnabled, +} from './subscriberRuntime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const reactRoot = createRoot(container) + let didUnmount = false + + reactRoot.render() + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + reactRoot.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routeTree.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routeTree.tsx new file mode 100644 index 0000000000..bf14bdf2b9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routeTree.tsx @@ -0,0 +1,8 @@ +import { rootRoute } from './routes/__root' +import { stateRoute } from './routes/state' +import { sectionRoute } from './routes/state.$section' +import { itemRoute } from './routes/state.$section.$itemId' + +export const routeTree = rootRoute.addChildren([ + stateRoute.addChildren([sectionRoute.addChildren([itemRoute])]), +]) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/router.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/router.tsx new file mode 100644 index 0000000000..eb0eb49655 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { subscribersSelectorsInitialLocation } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [subscribersSelectorsInitialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routerStateSubscribers.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routerStateSubscribers.tsx new file mode 100644 index 0000000000..1b50884ce7 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routerStateSubscribers.tsx @@ -0,0 +1,82 @@ +import { useRouterState } from '@tanstack/react-router' +import { subscriberIndices } from '../../shared' +import { SubscriberValue } from './subscriberValue' + +function RouterPathSubscriber(props: { index: number }) { + const value = useRouterState({ + select: (state) => state.location.pathname.length, + }) + + return +} + +function RouterStatusSubscriber(props: { index: number }) { + const value = useRouterState({ + select: (state) => ({ + status: state.status, + loading: state.isLoading, + }), + structuralSharing: true, + }) + + return ( + + ) +} + +function RouterHashSubscriber(props: { index: number }) { + const value = useRouterState({ + select: (state) => state.location.hash, + }) + + return +} + +function RouterSearchObjectSubscriber(props: { index: number }) { + const value = useRouterState({ + select: (state) => { + const search = state.location.search as Partial<{ + mode: string + objectKey: number + }> + + return { + mode: search.mode ?? '', + objectKey: Number(search.objectKey ?? 0), + } + }, + structuralSharing: true, + }) + + return ( + + ) +} + +export function RouterStateSubscribers() { + return ( + <> + {subscriberIndices.routerState.map((index) => { + const group = index % 4 + + if (group === 0) { + return + } + + if (group === 1) { + return + } + + if (group === 2) { + return + } + + return + })} + + ) +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/__root.tsx new file mode 100644 index 0000000000..edc04c397b --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.$section.$itemId.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.$section.$itemId.tsx new file mode 100644 index 0000000000..9dc2f6b0e9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.$section.$itemId.tsx @@ -0,0 +1,115 @@ +import { createRoute, useMatches, useParams } from '@tanstack/react-router' +import { + stringToSubscriberSeed, + subscriberGroupSize, + subscriberIndices, +} from '../../../shared' +import { SubscriberValue } from '../subscriberValue' +import { sectionRoute } from './state.$section' + +function ParamSectionSubscriber(props: { index: number }) { + const value = useParams({ + strict: false, + select: (params) => stringToSubscriberSeed(String(params.section ?? '')), + }) + + return ( + + ) +} + +function ParamItemSubscriber(props: { index: number }) { + const value = useParams({ + strict: false, + select: (params) => stringToSubscriberSeed(String(params.itemId ?? '')), + }) + + return +} + +function ParamObjectSubscriber(props: { index: number }) { + const value = useParams({ + strict: false, + select: (params) => ({ + section: String(params.section ?? ''), + itemId: String(params.itemId ?? ''), + }), + structuralSharing: true, + }) + + return ( + + ) +} + +function ParamSubscribers() { + return ( + <> + {subscriberIndices.params.map((index) => { + if (index < subscriberGroupSize * 2) { + return + } + + if (index < subscriberGroupSize * 3) { + return + } + + return + })} + + ) +} + +function MatchesDepthSubscriber(props: { index: number }) { + const value = useMatches({ + select: (matches) => matches.length, + }) + + return ( + + ) +} + +function MatchObjectSubscriber(props: { index: number }) { + const value = itemRoute.useMatch({ + select: (match) => ({ + id: match.id, + section: String(match.params.section ?? ''), + itemId: String(match.params.itemId ?? ''), + }), + structuralSharing: true, + }) + + return ( + + ) +} + +function MatchSubscribers() { + return ( + <> + {subscriberIndices.matches.map((index) => { + if (index < subscriberGroupSize) { + return + } + + return + })} + + ) +} + +function ItemPage() { + return ( + <> + + + + ) +} + +export const itemRoute = createRoute({ + getParentRoute: () => sectionRoute, + path: '$itemId', + component: ItemPage, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.$section.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.$section.tsx new file mode 100644 index 0000000000..ebd53afaea --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.$section.tsx @@ -0,0 +1,12 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { stateRoute } from './state' + +function SectionLayout() { + return +} + +export const sectionRoute = createRoute({ + getParentRoute: () => stateRoute, + path: '$section', + component: SectionLayout, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.tsx new file mode 100644 index 0000000000..5ebdf11ec5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/routes/state.tsx @@ -0,0 +1,92 @@ +import { Outlet, createRoute } from '@tanstack/react-router' +import { + normalizeSubscriberSearch, + subscriberGroupSize, + subscriberIndices, + subscribersSelectorsScenarioSlug, +} from '../../../shared' +import { RouterStateSubscribers } from '../routerStateSubscribers' +import { SubscriberValue } from '../subscriberValue' +import { rootRoute } from './__root' + +function SearchSelectedSubscriber(props: { index: number }) { + const value = stateRoute.useSearch({ + select: (search) => search.selected, + }) + + return ( + + ) +} + +function SearchObjectSubscriber(props: { index: number }) { + const value = stateRoute.useSearch({ + select: (search) => ({ + mode: search.mode, + objectKey: search.objectKey, + }), + structuralSharing: true, + }) + + return ( + + ) +} + +function SearchStableSubscriber(props: { index: number }) { + const value = stateRoute.useSearch({ + select: (search) => search.stable, + }) + + return ( + + ) +} + +function SearchModeSubscriber(props: { index: number }) { + const value = stateRoute.useSearch({ + select: (search) => search.mode.length + search.objectKey, + }) + + return +} + +function SearchSubscribers() { + return ( + <> + {subscriberIndices.search.map((index) => { + if (index < subscriberGroupSize) { + return + } + + if (index < subscriberGroupSize * 2) { + return + } + + if (index < subscriberGroupSize * 3) { + return + } + + return + })} + + ) +} + +function StateLayout() { + return ( + <> +
+ + + + + ) +} + +export const stateRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/state', + validateSearch: normalizeSubscriberSearch, + component: StateLayout, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/subscriberRuntime.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/subscriberRuntime.ts new file mode 100644 index 0000000000..a4e2edb724 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/subscriberRuntime.ts @@ -0,0 +1,9 @@ +import { createSubscribersSelectorsRuntime } from '../../shared' + +export const { + computeSubscriberValue, + getSubscriberCounts, + recordSubscriberUpdate, + resetSubscriberCounts, + setSubscriberCountersEnabled, +} = createSubscribersSelectorsRuntime() diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/subscriberValue.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/subscriberValue.tsx new file mode 100644 index 0000000000..cdd4b1531a --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/src/subscriberValue.tsx @@ -0,0 +1,19 @@ +import { + computeSubscriberValue, + recordSubscriberUpdate, +} from './subscriberRuntime' +import type { SubscriberCounterKey } from '../../shared' + +export function SubscriberValue(props: { + kind: SubscriberCounterKey + index: number + value: unknown +}) { + recordSubscriberUpdate(props.kind) + + return ( + + {computeSubscriberValue(props.index, props.value)} + + ) +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/tsconfig.json b/benchmarks/client-nav/scenarios/subscribers-selectors/react/tsconfig.json new file mode 100644 index 0000000000..aeb5270bf2 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "jsxImportSource": "react", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.tsx", + "../shared.ts", + "../workload.ts", + "../../../jsdom.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/react/vite.config.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/react/vite.config.ts new file mode 100644 index 0000000000..adc17c4455 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/react/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav subscribers-selectors (react)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/shared.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/shared.ts new file mode 100644 index 0000000000..449ee818d0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/shared.ts @@ -0,0 +1,283 @@ +export const subscribersSelectorsScenarioSlug = 'subscribers-selectors' + +export const subscriberCounts = { + routerState: 80, + search: 80, + params: 80, + matches: 40, +} as const + +export const subscriberGroupSize = 20 +export const subscribersSelectorsActionsPerRun = 24 + +export type SubscriberCounterKey = + | 'routerPath' + | 'routerStatus' + | 'routerHash' + | 'routerSearchObject' + | 'searchSelected' + | 'searchObject' + | 'searchStable' + | 'searchMode' + | 'paramSection' + | 'paramItem' + | 'paramObject' + | 'matchesDepth' + | 'matchObject' + +export type SubscriberCounts = Record + +export interface SubscriberSearch { + selected: number + mode: string + objectKey: number + stable: string + unrelated: string +} + +export interface SubscribersSelectorsAction { + section: string + itemId: string + search: SubscriberSearch + hash: string +} + +const modeValues = ['summary', 'details', 'related', 'audit'] as const +const sectionValues = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const + +function createIndices(count: number) { + const indices: Array = [] + + for (let index = 0; index < count; index++) { + indices.push(index) + } + + return indices +} + +export const subscriberIndices = { + routerState: createIndices(subscriberCounts.routerState), + search: createIndices(subscriberCounts.search), + params: createIndices(subscriberCounts.params), + matches: createIndices(subscriberCounts.matches), +} as const + +export function createEmptySubscriberCounts(): SubscriberCounts { + return { + routerPath: 0, + routerStatus: 0, + routerHash: 0, + routerSearchObject: 0, + searchSelected: 0, + searchObject: 0, + searchStable: 0, + searchMode: 0, + paramSection: 0, + paramItem: 0, + paramObject: 0, + matchesDepth: 0, + matchObject: 0, + } +} + +export function runSubscriberComputation(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 12; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function stringToSubscriberSeed(value: string | undefined) { + let seed = 0 + const input = value ?? '' + + for (let index = 0; index < input.length; index++) { + seed = (seed * 31 + input.charCodeAt(index)) >>> 0 + } + + return seed +} + +export function digestSubscriberValue(value: unknown): number { + if (typeof value === 'number') { + return value + } + + if (typeof value === 'string') { + return stringToSubscriberSeed(value) + } + + if (typeof value === 'boolean') { + return value ? 1 : 0 + } + + if (value && typeof value === 'object') { + let seed = 17 + + for (const [key, item] of Object.entries( + value as Record, + )) { + seed = + (seed + + stringToSubscriberSeed(key) * 13 + + digestSubscriberValue(item)) >>> + 0 + } + + return seed + } + + return 0 +} + +export function createSubscribersSelectorsRuntime() { + let subscriberCounts = createEmptySubscriberCounts() + let subscriberCountersEnabled = false + + return { + resetSubscriberCounts() { + subscriberCounts = createEmptySubscriberCounts() + }, + getSubscriberCounts(): SubscriberCounts { + return { ...subscriberCounts } + }, + setSubscriberCountersEnabled(enabled: boolean) { + subscriberCountersEnabled = enabled + }, + recordSubscriberUpdate(kind: SubscriberCounterKey) { + if (subscriberCountersEnabled) { + subscriberCounts[kind] += 1 + } + }, + computeSubscriberValue(index: number, value: unknown) { + return runSubscriberComputation(digestSubscriberValue(value) + index) + }, + } +} + +export function normalizeSubscriberSearch( + search: Record, +): SubscriberSearch { + const selected = Number(search.selected) + const objectKey = Number(search.objectKey) + + return { + selected: Number.isFinite(selected) ? Math.trunc(selected) : 0, + mode: typeof search.mode === 'string' ? search.mode : modeValues[0], + objectKey: Number.isFinite(objectKey) ? Math.trunc(objectKey) : 0, + stable: typeof search.stable === 'string' ? search.stable : 'stable-0', + unrelated: + typeof search.unrelated === 'string' ? search.unrelated : 'unused-0-0', + } +} + +export function buildSubscriberSearch( + round: number, + variant: number, +): SubscriberSearch { + return { + selected: round * 10 + variant, + mode: modeValues[(round + variant) % modeValues.length]!, + objectKey: round * 100 + variant, + stable: `stable-${round % 2}`, + unrelated: `unused-${round}-${variant}`, + } +} + +function createNavigationActions() { + const actions: Array = [] + + for (let round = 0; round < 4; round++) { + const baseSection = sectionValues[(round * 2) % sectionValues.length]! + const nextSection = sectionValues[(round * 2 + 1) % sectionValues.length]! + const baseSearch = buildSubscriberSearch(round, 0) + const selectedSearch = { + ...baseSearch, + selected: baseSearch.selected + 1, + } + const unrelatedSearch = { + ...selectedSearch, + unrelated: `unused-only-${round}`, + } + const hash = `round-${round}` + + actions.push({ + section: baseSection, + itemId: '1', + search: baseSearch, + hash, + }) + actions.push({ + section: baseSection, + itemId: '1', + search: selectedSearch, + hash, + }) + actions.push({ + section: baseSection, + itemId: '1', + search: unrelatedSearch, + hash, + }) + actions.push({ + section: baseSection, + itemId: '2', + search: unrelatedSearch, + hash, + }) + actions.push({ + section: nextSection, + itemId: '2', + search: unrelatedSearch, + hash, + }) + actions.push({ + section: nextSection, + itemId: '2', + search: unrelatedSearch, + hash: `${hash}-hash-only`, + }) + } + + if (actions.length !== subscribersSelectorsActionsPerRun) { + throw new Error( + `Expected ${subscribersSelectorsActionsPerRun} subscriber selector actions, got ${actions.length}`, + ) + } + + return actions +} + +function encodeSearchValue(value: string | number) { + return encodeURIComponent(String(value)) +} + +export function buildSubscribersSelectorsHref( + action: SubscribersSelectorsAction, +) { + const search = action.search + const query = [ + `selected=${encodeSearchValue(search.selected)}`, + `mode=${encodeSearchValue(search.mode)}`, + `objectKey=${encodeSearchValue(search.objectKey)}`, + `stable=${encodeSearchValue(search.stable)}`, + `unrelated=${encodeSearchValue(search.unrelated)}`, + ].join('&') + + return `/state/${action.section}/${action.itemId}?${query}#${action.hash}` +} + +export const subscribersSelectorsInitialAction: SubscribersSelectorsAction = { + section: 'seed', + itemId: '0', + search: buildSubscriberSearch(99, 0), + hash: 'seed', +} + +export const subscribersSelectorsInitialLocation = + buildSubscribersSelectorsHref(subscribersSelectorsInitialAction) + +export const subscribersSelectorsNavigationActions = createNavigationActions() diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/project.json b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/project.json new file mode 100644 index 0000000000..025fba5ff0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-subscribers-selectors-solid", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/solid-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/setup.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/setup.ts new file mode 100644 index 0000000000..e03c0f0f6c --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createSubscribersSelectorsWorkload } from '../workload.ts' + +const appModulePath = './dist/app.js' +const app = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createSubscribersSelectorsWorkload('solid', app) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/speed.bench.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/speed.flame.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/speed.flame.ts new file mode 100644 index 0000000000..c96317c25b --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/speed.flame.ts @@ -0,0 +1,19 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/app.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/app.tsx new file mode 100644 index 0000000000..118aea5c92 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/app.tsx @@ -0,0 +1,27 @@ +import { render } from 'solid-js/web' +import { RouterProvider } from '@tanstack/solid-router' +import { getRouter } from './router' + +export { + getSubscriberCounts, + resetSubscriberCounts, + setSubscriberCountersEnabled, +} from './subscriberRuntime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const dispose = render(() => , container) + let didUnmount = false + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + dispose() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routeTree.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routeTree.tsx new file mode 100644 index 0000000000..bf14bdf2b9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routeTree.tsx @@ -0,0 +1,8 @@ +import { rootRoute } from './routes/__root' +import { stateRoute } from './routes/state' +import { sectionRoute } from './routes/state.$section' +import { itemRoute } from './routes/state.$section.$itemId' + +export const routeTree = rootRoute.addChildren([ + stateRoute.addChildren([sectionRoute.addChildren([itemRoute])]), +]) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/router.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/router.tsx new file mode 100644 index 0000000000..1661bfd305 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { subscribersSelectorsInitialLocation } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [subscribersSelectorsInitialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routerStateSubscribers.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routerStateSubscribers.tsx new file mode 100644 index 0000000000..5cb942eb6b --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routerStateSubscribers.tsx @@ -0,0 +1,81 @@ +import { For } from 'solid-js' +import { useRouterState } from '@tanstack/solid-router' +import { subscriberIndices } from '../../shared' +import { SubscriberValue } from './subscriberValue' + +function RouterPathSubscriber(props: { index: number }) { + const value = useRouterState({ + select: (state) => state.location.pathname.length, + }) + + return +} + +function RouterStatusSubscriber(props: { index: number }) { + const value = useRouterState({ + select: (state) => ({ + status: state.status, + loading: state.isLoading, + }), + }) + + return ( + + ) +} + +function RouterHashSubscriber(props: { index: number }) { + const value = useRouterState({ + select: (state) => state.location.hash, + }) + + return +} + +function RouterSearchObjectSubscriber(props: { index: number }) { + const value = useRouterState({ + select: (state) => { + const search = state.location.search as Partial<{ + mode: string + objectKey: number + }> + + return { + mode: search.mode ?? '', + objectKey: Number(search.objectKey ?? 0), + } + }, + }) + + return ( + + ) +} + +export function RouterStateSubscribers() { + return ( + + {(index) => { + const group = index % 4 + + if (group === 0) { + return + } + + if (group === 1) { + return + } + + if (group === 2) { + return + } + + return + }} + + ) +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..1e9054643e --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const rootRoute = createRootRoute({ + component: Root, +}) + +function Root() { + return +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.$section.$itemId.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.$section.$itemId.tsx new file mode 100644 index 0000000000..4cc21c06b8 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.$section.$itemId.tsx @@ -0,0 +1,114 @@ +import { For } from 'solid-js' +import { createRoute, useMatches, useParams } from '@tanstack/solid-router' +import { + stringToSubscriberSeed, + subscriberGroupSize, + subscriberIndices, +} from '../../../shared' +import { SubscriberValue } from '../subscriberValue' +import { sectionRoute } from './state.$section' + +function ParamSectionSubscriber(props: { index: number }) { + const value = useParams({ + strict: false, + select: (params) => stringToSubscriberSeed(String(params.section ?? '')), + }) + + return ( + + ) +} + +function ParamItemSubscriber(props: { index: number }) { + const value = useParams({ + strict: false, + select: (params) => stringToSubscriberSeed(String(params.itemId ?? '')), + }) + + return +} + +function ParamObjectSubscriber(props: { index: number }) { + const value = useParams({ + strict: false, + select: (params) => ({ + section: String(params.section ?? ''), + itemId: String(params.itemId ?? ''), + }), + }) + + return ( + + ) +} + +function ParamSubscribers() { + return ( + + {(index) => { + if (index < subscriberGroupSize * 2) { + return + } + + if (index < subscriberGroupSize * 3) { + return + } + + return + }} + + ) +} + +function MatchesDepthSubscriber(props: { index: number }) { + const value = useMatches({ + select: (matches) => matches.length, + }) + + return ( + + ) +} + +function MatchObjectSubscriber(props: { index: number }) { + const value = itemRoute.useMatch({ + select: (match) => ({ + id: match.id, + section: String(match.params.section ?? ''), + itemId: String(match.params.itemId ?? ''), + }), + }) + + return ( + + ) +} + +function MatchSubscribers() { + return ( + + {(index) => { + if (index < subscriberGroupSize) { + return + } + + return + }} + + ) +} + +function ItemPage() { + return ( + <> + + + + ) +} + +export const itemRoute = createRoute({ + getParentRoute: () => sectionRoute, + path: '$itemId', + component: ItemPage, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.$section.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.$section.tsx new file mode 100644 index 0000000000..6501af4537 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.$section.tsx @@ -0,0 +1,12 @@ +import { Outlet, createRoute } from '@tanstack/solid-router' +import { stateRoute } from './state' + +function SectionLayout() { + return +} + +export const sectionRoute = createRoute({ + getParentRoute: () => stateRoute, + path: '$section', + component: SectionLayout, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.tsx new file mode 100644 index 0000000000..34eb77ae6a --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/routes/state.tsx @@ -0,0 +1,92 @@ +import { For } from 'solid-js' +import { Outlet, createRoute } from '@tanstack/solid-router' +import { + normalizeSubscriberSearch, + subscriberGroupSize, + subscriberIndices, + subscribersSelectorsScenarioSlug, +} from '../../../shared' +import { RouterStateSubscribers } from '../routerStateSubscribers' +import { SubscriberValue } from '../subscriberValue' +import { rootRoute } from './__root' + +function SearchSelectedSubscriber(props: { index: number }) { + const value = stateRoute.useSearch({ + select: (search) => search.selected, + }) + + return ( + + ) +} + +function SearchObjectSubscriber(props: { index: number }) { + const value = stateRoute.useSearch({ + select: (search) => ({ + mode: search.mode, + objectKey: search.objectKey, + }), + }) + + return ( + + ) +} + +function SearchStableSubscriber(props: { index: number }) { + const value = stateRoute.useSearch({ + select: (search) => search.stable, + }) + + return ( + + ) +} + +function SearchModeSubscriber(props: { index: number }) { + const value = stateRoute.useSearch({ + select: (search) => search.mode.length + search.objectKey, + }) + + return +} + +function SearchSubscribers() { + return ( + + {(index) => { + if (index < subscriberGroupSize) { + return + } + + if (index < subscriberGroupSize * 2) { + return + } + + if (index < subscriberGroupSize * 3) { + return + } + + return + }} + + ) +} + +function StateLayout() { + return ( + <> +
+ + + + + ) +} + +export const stateRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/state', + validateSearch: normalizeSubscriberSearch, + component: StateLayout, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/subscriberRuntime.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/subscriberRuntime.ts new file mode 100644 index 0000000000..a4e2edb724 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/subscriberRuntime.ts @@ -0,0 +1,9 @@ +import { createSubscribersSelectorsRuntime } from '../../shared' + +export const { + computeSubscriberValue, + getSubscriberCounts, + recordSubscriberUpdate, + resetSubscriberCounts, + setSubscriberCountersEnabled, +} = createSubscribersSelectorsRuntime() diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/subscriberValue.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/subscriberValue.tsx new file mode 100644 index 0000000000..aef3f7b7ab --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/src/subscriberValue.tsx @@ -0,0 +1,23 @@ +import { createMemo, createRenderEffect } from 'solid-js' +import { + computeSubscriberValue, + recordSubscriberUpdate, +} from './subscriberRuntime' +import type { SubscriberCounterKey } from '../../shared' + +export function SubscriberValue(props: { + kind: SubscriberCounterKey + index: number + value: () => unknown +}) { + const text = createMemo(() => + computeSubscriberValue(props.index, props.value()), + ) + + createRenderEffect(() => { + text() + recordSubscriberUpdate(props.kind) + }) + + return {text()} +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/tsconfig.json b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/tsconfig.json new file mode 100644 index 0000000000..81bab69e3f --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "solid-js", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.tsx", + "../shared.ts", + "../workload.ts", + "../../../jsdom.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/solid/vite.config.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/vite.config.ts new file mode 100644 index 0000000000..e65b4f045f --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/solid/vite.config.ts @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + solid({ hot: false, dev: false }), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + resolve: { + conditions: ['solid', 'browser'], + }, + test: { + name: '@benchmarks/client-nav subscribers-selectors (solid)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + server: { + deps: { + inline: [/@solidjs/, /@tanstack\/solid-store/], + }, + }, + }, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/project.json b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/project.json new file mode 100644 index 0000000000..d108e44fd5 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/project.json @@ -0,0 +1,54 @@ +{ + "name": "@benchmarks/client-nav-subscribers-selectors-vue", + "projectType": "application", + "targets": { + "build:client": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" + } + }, + "build:client:flame": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts --sourcemap true" + } + }, + "test:flame": { + "executor": "nx:run-commands", + "parallelism": false, + "cache": false, + "dependsOn": ["build:client:flame"], + "options": { + "command": "NODE_ENV=production flame run --md-format=detailed --delay=none --node-options=\"--stack-size=65500\" {projectRoot}/speed.flame.ts", + "cwd": "." + } + }, + "test:types:client": { + "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@tanstack/vue-router"], + "target": "build" + } + ], + "options": { + "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" + } + } + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/setup.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/setup.ts new file mode 100644 index 0000000000..f36630470a --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/setup.ts @@ -0,0 +1,7 @@ +import type * as App from './src/app' +import { createSubscribersSelectorsWorkload } from '../workload.ts' + +const appModulePath = './dist/app.js' +const app = (await import(/* @vite-ignore */ appModulePath)) as typeof App + +export const workload = createSubscribersSelectorsWorkload('vue', app) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/speed.bench.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/speed.bench.ts new file mode 100644 index 0000000000..0e553d249d --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/speed.bench.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' +import { workload } from './setup' + +await workload.sanity() + +describe('client-nav', () => { + beforeAll(workload.before) + afterAll(workload.after) + + bench(workload.name, workload.run, { + ...clientNavBenchOptions, + setup: workload.before, + teardown: workload.after, + }) +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/speed.flame.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/speed.flame.ts new file mode 100644 index 0000000000..c96317c25b --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/speed.flame.ts @@ -0,0 +1,19 @@ +import { window } from '../../../jsdom.ts' +import { workload } from './setup.ts' + +const DURATION_MS = 10_000 + +await workload.sanity() + +try { + await workload.before() + + const startedAt = performance.now() + + while (performance.now() - startedAt < DURATION_MS) { + await workload.run() + } +} finally { + await workload.after() + window.close() +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/app.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/app.tsx new file mode 100644 index 0000000000..e86de4f667 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/app.tsx @@ -0,0 +1,31 @@ +import { RouterProvider } from '@tanstack/vue-router' +import { createApp } from 'vue' +import { getRouter } from './router' + +export { + getSubscriberCounts, + resetSubscriberCounts, + setSubscriberCountersEnabled, +} from './subscriberRuntime' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = createApp({ + render: () => , + }) + let didUnmount = false + + app.mount(container) + + return { + router, + unmount() { + if (didUnmount) { + return + } + + didUnmount = true + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routeTree.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routeTree.tsx new file mode 100644 index 0000000000..bf14bdf2b9 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routeTree.tsx @@ -0,0 +1,8 @@ +import { rootRoute } from './routes/__root' +import { stateRoute } from './routes/state' +import { sectionRoute } from './routes/state.$section' +import { itemRoute } from './routes/state.$section.$itemId' + +export const routeTree = rootRoute.addChildren([ + stateRoute.addChildren([sectionRoute.addChildren([itemRoute])]), +]) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/router.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/router.tsx new file mode 100644 index 0000000000..da6f08db49 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { subscribersSelectorsInitialLocation } from '../../shared' +import { routeTree } from './routeTree' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: [subscribersSelectorsInitialLocation], + }), + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routerStateSubscribers.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routerStateSubscribers.tsx new file mode 100644 index 0000000000..2085c7ac8b --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routerStateSubscribers.tsx @@ -0,0 +1,131 @@ +import * as Vue from 'vue' +import { useRouterState } from '@tanstack/vue-router' +import { subscriberIndices } from '../../shared' +import { SubscriberValue } from './subscriberValue' + +const RouterPathSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = useRouterState({ + select: (state) => state.location.pathname.length, + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const RouterStatusSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = useRouterState({ + select: (state) => ({ + status: state.status, + loading: state.isLoading, + }), + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const RouterHashSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = useRouterState({ + select: (state) => state.location.hash, + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const RouterSearchObjectSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = useRouterState({ + select: (state) => { + const search = state.location.search as Partial<{ + mode: string + objectKey: number + }> + + return { + mode: search.mode ?? '', + objectKey: Number(search.objectKey ?? 0), + } + }, + }) + + return () => ( + value.value} + /> + ) + }, +}) + +export const RouterStateSubscribers = Vue.defineComponent({ + setup() { + return () => ( + <> + {subscriberIndices.routerState.map((index) => { + const group = index % 4 + + if (group === 0) { + return + } + + if (group === 1) { + return + } + + if (group === 2) { + return + } + + return + })} + + ) + }, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/__root.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..1904cf90ef --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/__root.tsx @@ -0,0 +1,12 @@ +import * as Vue from 'vue' +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +const Root = Vue.defineComponent({ + setup() { + return () => + }, +}) + +export const rootRoute = createRootRoute({ + component: Root, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.$section.$itemId.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.$section.$itemId.tsx new file mode 100644 index 0000000000..3b54945738 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.$section.$itemId.tsx @@ -0,0 +1,182 @@ +import * as Vue from 'vue' +import { createRoute, useMatches, useParams } from '@tanstack/vue-router' +import { + stringToSubscriberSeed, + subscriberGroupSize, + subscriberIndices, +} from '../../../shared' +import { SubscriberValue } from '../subscriberValue' +import { sectionRoute } from './state.$section' + +const ParamSectionSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = useParams({ + strict: false, + select: (params) => stringToSubscriberSeed(String(params.section ?? '')), + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const ParamItemSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = useParams({ + strict: false, + select: (params) => stringToSubscriberSeed(String(params.itemId ?? '')), + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const ParamObjectSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = useParams({ + strict: false, + select: (params) => ({ + section: String(params.section ?? ''), + itemId: String(params.itemId ?? ''), + }), + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const ParamSubscribers = Vue.defineComponent({ + setup() { + return () => ( + <> + {subscriberIndices.params.map((index) => { + if (index < subscriberGroupSize * 2) { + return + } + + if (index < subscriberGroupSize * 3) { + return + } + + return + })} + + ) + }, +}) + +const MatchesDepthSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = useMatches({ + select: (matches) => matches.length, + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const MatchObjectSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = itemRoute.useMatch({ + select: (match) => ({ + id: match.id, + section: String(match.params.section ?? ''), + itemId: String(match.params.itemId ?? ''), + }), + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const MatchSubscribers = Vue.defineComponent({ + setup() { + return () => ( + <> + {subscriberIndices.matches.map((index) => { + if (index < subscriberGroupSize) { + return + } + + return + })} + + ) + }, +}) + +const ItemPage = Vue.defineComponent({ + setup() { + return () => ( + <> + + + + ) + }, +}) + +export const itemRoute = createRoute({ + getParentRoute: () => sectionRoute, + path: '$itemId', + component: ItemPage, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.$section.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.$section.tsx new file mode 100644 index 0000000000..c4caaf5ac0 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.$section.tsx @@ -0,0 +1,15 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { stateRoute } from './state' + +const SectionLayout = Vue.defineComponent({ + setup() { + return () => + }, +}) + +export const sectionRoute = createRoute({ + getParentRoute: () => stateRoute, + path: '$section', + component: SectionLayout, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.tsx new file mode 100644 index 0000000000..3fc0bb5ef4 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/routes/state.tsx @@ -0,0 +1,146 @@ +import * as Vue from 'vue' +import { Outlet, createRoute } from '@tanstack/vue-router' +import { + normalizeSubscriberSearch, + subscriberGroupSize, + subscriberIndices, + subscribersSelectorsScenarioSlug, +} from '../../../shared' +import { RouterStateSubscribers } from '../routerStateSubscribers' +import { SubscriberValue } from '../subscriberValue' +import { rootRoute } from './__root' + +const SearchSelectedSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = stateRoute.useSearch({ + select: (search) => search.selected, + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const SearchObjectSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = stateRoute.useSearch({ + select: (search) => ({ + mode: search.mode, + objectKey: search.objectKey, + }), + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const SearchStableSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = stateRoute.useSearch({ + select: (search) => search.stable, + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const SearchModeSubscriber = Vue.defineComponent({ + props: { + index: { + type: Number, + required: true, + }, + }, + setup(props) { + const value = stateRoute.useSearch({ + select: (search) => search.mode.length + search.objectKey, + }) + + return () => ( + value.value} + /> + ) + }, +}) + +const SearchSubscribers = Vue.defineComponent({ + setup() { + return () => ( + <> + {subscriberIndices.search.map((index) => { + if (index < subscriberGroupSize) { + return + } + + if (index < subscriberGroupSize * 2) { + return + } + + if (index < subscriberGroupSize * 3) { + return + } + + return + })} + + ) + }, +}) + +const StateLayout = Vue.defineComponent({ + setup() { + return () => ( + <> +
+ + + + + ) + }, +}) + +export const stateRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/state', + validateSearch: normalizeSubscriberSearch, + component: StateLayout, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/subscriberRuntime.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/subscriberRuntime.ts new file mode 100644 index 0000000000..a4e2edb724 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/subscriberRuntime.ts @@ -0,0 +1,9 @@ +import { createSubscribersSelectorsRuntime } from '../../shared' + +export const { + computeSubscriberValue, + getSubscriberCounts, + recordSubscriberUpdate, + resetSubscriberCounts, + setSubscriberCountersEnabled, +} = createSubscribersSelectorsRuntime() diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/subscriberValue.tsx b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/subscriberValue.tsx new file mode 100644 index 0000000000..473965848c --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/src/subscriberValue.tsx @@ -0,0 +1,34 @@ +import * as Vue from 'vue' +import { + computeSubscriberValue, + recordSubscriberUpdate, +} from './subscriberRuntime' +import type { SubscriberCounterKey } from '../../shared' + +export const SubscriberValue = Vue.defineComponent({ + props: { + kind: { + type: String as Vue.PropType, + required: true, + }, + index: { + type: Number, + required: true, + }, + value: { + type: Function as Vue.PropType<() => unknown>, + required: true, + }, + }, + setup(props) { + return () => { + recordSubscriberUpdate(props.kind) + + return ( + + {computeSubscriberValue(props.index, props.value())} + + ) + } + }, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/tsconfig.json b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/tsconfig.json new file mode 100644 index 0000000000..cd85d42f8b --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "allowImportingTsExtensions": true, + "jsxImportSource": "vue", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": [ + "speed.bench.ts", + "speed.flame.ts", + "setup.ts", + "vite.config.ts", + "src/**/*.tsx", + "../shared.ts", + "../workload.ts", + "../../../jsdom.ts", + "../../../vitest.setup.ts" + ] +} diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/vue/vite.config.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/vite.config.ts new file mode 100644 index 0000000000..0942154289 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/vue/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import codspeedPlugin from '@codspeed/vitest-plugin' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) +const setupFile = fileURLToPath( + new URL('../../../vitest.setup.ts', import.meta.url), +) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [ + !!(process.env.VITEST && process.env.WITH_INSTRUMENTATION) && + codspeedPlugin(), + vue(), + vueJsx(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + lib: { + entry: './src/app.tsx', + formats: ['es'], + fileName: 'app', + }, + }, + test: { + name: '@benchmarks/client-nav subscribers-selectors (vue)', + watch: false, + environment: 'jsdom', + setupFiles: [setupFile], + }, +}) diff --git a/benchmarks/client-nav/scenarios/subscribers-selectors/workload.ts b/benchmarks/client-nav/scenarios/subscribers-selectors/workload.ts new file mode 100644 index 0000000000..71dd71efc1 --- /dev/null +++ b/benchmarks/client-nav/scenarios/subscribers-selectors/workload.ts @@ -0,0 +1,200 @@ +import type { ClientNavWorkload } from '#client-nav/benchmark' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' +import type { Framework, MountTestApp } from '#client-nav/lifecycle' +import { + subscribersSelectorsNavigationActions, + subscribersSelectorsScenarioSlug, +} from './shared.ts' +import type { SubscriberCounts, SubscribersSelectorsAction } from './shared.ts' + +interface SubscribersSelectorsApp { + mountTestApp: MountTestApp + getSubscriberCounts: () => SubscriberCounts + resetSubscriberCounts: () => void + setSubscriberCountersEnabled: (enabled: boolean) => void +} + +function assertCounterAdvanced(label: string, before: number, after: number) { + if (after <= before) { + throw new Error( + `Expected ${label} counter to advance, before=${before}, after=${after}`, + ) + } +} + +function assertCounterStable(label: string, before: number, after: number) { + if (after !== before) { + throw new Error( + `Expected ${label} counter to stay stable, before=${before}, after=${after}`, + ) + } +} + +function assertLocation( + actualPathname: string, + actualHash: string, + action: SubscribersSelectorsAction, +) { + const expectedPathname = `/state/${action.section}/${action.itemId}` + + if (actualPathname !== expectedPathname) { + throw new Error( + `Expected pathname ${expectedPathname}, received ${actualPathname}`, + ) + } + + if (actualHash !== action.hash) { + throw new Error(`Expected hash ${action.hash}, received ${actualHash}`) + } +} + +export function createSubscribersSelectorsWorkload( + framework: Framework, + app: SubscribersSelectorsApp, +): ClientNavWorkload { + warnClientNavDevMode(framework) + + const lifecycle = createClientNavLifecycle({ mountTestApp: app.mountTestApp }) + + async function navigate(action: SubscribersSelectorsAction) { + await lifecycle.navigate({ + to: '/state/$section/$itemId', + params: { + section: action.section, + itemId: action.itemId, + }, + search: action.search, + hash: action.hash, + replace: true, + resetScroll: false, + hashScrollIntoView: false, + }) + } + + function assertScenarioMounted() { + const marker = lifecycle + .getContainer() + .querySelector( + `[data-client-nav-scenario="${subscribersSelectorsScenarioSlug}"]`, + ) + + if (!marker) { + throw new Error('Subscribers selectors scenario marker was not rendered') + } + } + + function assertCurrentLocation(action: SubscribersSelectorsAction) { + const { location } = lifecycle.getRouter().state + assertLocation(location.pathname, location.hash, action) + } + + async function before() { + app.setSubscriberCountersEnabled(false) + app.resetSubscriberCounts() + await lifecycle.before() + } + + async function run() { + for (const action of subscribersSelectorsNavigationActions) { + await navigate(action) + } + } + + async function sanity() { + app.setSubscriberCountersEnabled(true) + app.resetSubscriberCounts() + await lifecycle.before() + + try { + const [ + baseAction, + selectedAction, + unrelatedAction, + itemAction, + sectionAction, + hashAction, + ] = subscribersSelectorsNavigationActions + + await navigate(baseAction!) + assertScenarioMounted() + assertCurrentLocation(baseAction!) + + const baseCounts = app.getSubscriberCounts() + + await navigate(selectedAction!) + assertCurrentLocation(selectedAction!) + + const selectedCounts = app.getSubscriberCounts() + assertCounterAdvanced( + 'selected search', + baseCounts.searchSelected, + selectedCounts.searchSelected, + ) + assertCounterStable( + 'stable search after selected-key navigation', + baseCounts.searchStable, + selectedCounts.searchStable, + ) + + await navigate(unrelatedAction!) + assertCurrentLocation(unrelatedAction!) + + const unrelatedCounts = app.getSubscriberCounts() + assertCounterStable( + 'selected search after unrelated-key navigation', + selectedCounts.searchSelected, + unrelatedCounts.searchSelected, + ) + assertCounterStable( + 'stable search after unrelated-key navigation', + selectedCounts.searchStable, + unrelatedCounts.searchStable, + ) + + await navigate(itemAction!) + assertCurrentLocation(itemAction!) + + const itemCounts = app.getSubscriberCounts() + assertCounterAdvanced( + 'item param', + unrelatedCounts.paramItem, + itemCounts.paramItem, + ) + + await navigate(sectionAction!) + assertCurrentLocation(sectionAction!) + + const sectionCounts = app.getSubscriberCounts() + assertCounterAdvanced( + 'section param', + itemCounts.paramSection, + sectionCounts.paramSection, + ) + + await navigate(hashAction!) + assertCurrentLocation(hashAction!) + + const hashCounts = app.getSubscriberCounts() + assertCounterAdvanced( + 'router hash', + sectionCounts.routerHash, + hashCounts.routerHash, + ) + } finally { + await lifecycle.after() + app.resetSubscriberCounts() + app.setSubscriberCountersEnabled(false) + } + } + + return { + name: `client subscribers selectors loop (${framework})`, + before, + run, + sanity, + after: lifecycle.after, + } +} diff --git a/benchmarks/client-nav/setup-helpers.ts b/benchmarks/client-nav/setup-helpers.ts index c070018743..47231398b3 100644 --- a/benchmarks/client-nav/setup-helpers.ts +++ b/benchmarks/client-nav/setup-helpers.ts @@ -1,13 +1,4 @@ -export function getRequiredLink( - container: ParentNode, - testId: string, - cache?: Map, -) { - const cachedLink = cache?.get(testId) - if (cachedLink) { - return cachedLink - } - +export function getRequiredLink(container: ParentNode, testId: string) { const link = container.querySelector( `[data-testid="${testId}"]`, ) @@ -15,14 +6,12 @@ export function getRequiredLink( throw new Error(`Unable to find benchmark link: ${testId}`) } - cache?.set(testId, link) return link } export async function waitForRequiredLink( container: ParentNode, testId: string, - cache?: Map, ) { for (let attempt = 0; attempt < 10; attempt++) { const link = container.querySelector( @@ -30,7 +19,6 @@ export async function waitForRequiredLink( ) if (link) { - cache?.set(testId, link) return link } @@ -39,5 +27,5 @@ export async function waitForRequiredLink( }) } - return getRequiredLink(container, testId, cache) + return getRequiredLink(container, testId) } diff --git a/benchmarks/client-nav/solid/app.tsx b/benchmarks/client-nav/solid/app.tsx deleted file mode 100644 index ef90130f5e..0000000000 --- a/benchmarks/client-nav/solid/app.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import { For, createRenderEffect } from 'solid-js' -import { render } from 'solid-js/web' -import { - Link, - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - useParams, - useSearch, -} from '@tanstack/solid-router' - -function runPerfSelectorComputation(seed: number) { - let value = Math.trunc(seed) | 0 - - for (let index = 0; index < 40; index++) { - value = (value * 1664525 + 1013904223 + index) >>> 0 - } - - return value -} - -function normalizePage(value: unknown) { - const page = Number(value) - return Number.isFinite(page) && page > 0 ? Math.trunc(page) : 1 -} - -function normalizeFilter(value: unknown) { - return typeof value === 'string' && value.length > 0 ? value : 'all' -} - -const noop = () => {} -const rootSelectors = Array.from({ length: 10 }, (_, index) => index) -const routeSelectors = Array.from({ length: 6 }, (_, index) => index) -const linkGroups = Array.from({ length: 4 }, (_, index) => index) - -function PerfValue(props: { value: () => number }) { - createRenderEffect(() => { - void props.value() - }) - - return null -} - -function RootParamsSubscriber() { - const params = useParams({ - strict: false, - select: (params) => runPerfSelectorComputation(Number(params.id ?? 0)), - }) - - return runPerfSelectorComputation(params())} /> -} - -function RootSearchSubscriber() { - const search = useSearch({ - strict: false, - select: (search) => runPerfSelectorComputation(Number(search.page ?? 0)), - }) - - return runPerfSelectorComputation(search())} /> -} - -function LinkPanel() { - return ( - <> - - {(groupIndex) => { - const itemsId = groupIndex === 0 ? 1 : groupIndex + 2 - const ctxId = groupIndex + 1 - - return ( -
- - {`Items ${itemsId}`} - - - {`Items 2 alt ${groupIndex}`} - - - {`Search ${groupIndex}`} - - - {`Context ${ctxId}`} - - ({ - page: prev.page + groupIndex + 1, - filter: prev.filter, - junk: `updater-${groupIndex}`, - })} - activeOptions={{ includeSearch: true }} - > - {({ isActive }) => - isActive - ? `Search updater active ${groupIndex}` - : `Search updater inactive ${groupIndex}` - } - -
- ) - }} -
- - ) -} - -function Root() { - return ( - <> - {() => } - {() => } - - - - ) -} - -const rootRoute = createRootRoute({ - component: Root, -}) - -const itemsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/items/$id', - params: { - parse: (params) => ({ - ...params, - id: normalizePage(params.id), - }), - stringify: (params) => ({ - ...params, - id: `${params.id}`, - }), - }, - onEnter: noop, - onStay: noop, - onLeave: noop, - component: ItemsPage, -}) - -const itemDetailsRoute = createRoute({ - getParentRoute: () => itemsRoute, - path: 'details', - component: ItemDetailsPage, -}) - -const searchRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/search', - validateSearch: (search: Record) => ({ - page: normalizePage(search.page), - filter: normalizeFilter(search.filter), - }), - search: { - middlewares: [ - ({ search, next }) => { - const result = next(search) - return { - page: result.page, - filter: result.filter, - } - }, - ], - }, - loaderDeps: ({ search }) => ({ - page: search.page, - filter: search.filter, - }), - loader: ({ deps }) => ({ - seed: deps.page * 31 + deps.filter.length, - checksum: deps.page * 17 + deps.filter.length, - }), - staleTime: 60_000, - gcTime: 60_000, - component: SearchPage, -}) - -const contextRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/ctx/$id', - beforeLoad: ({ params }) => ({ - sectionSeed: Number(params.id) * 13 + 1, - }), - component: ContextPage, -}) - -function ItemParamsSubscriber() { - const params = itemsRoute.useParams({ - select: (params) => runPerfSelectorComputation(params.id), - }) - - return runPerfSelectorComputation(params())} /> -} - -function SearchStateSubscriber() { - const search = searchRoute.useSearch({ - select: (search) => - runPerfSelectorComputation(search.page + search.filter.length), - }) - - return runPerfSelectorComputation(search())} /> -} - -function SearchLoaderDepsSubscriber() { - const loaderDeps = searchRoute.useLoaderDeps({ - select: (loaderDeps) => - runPerfSelectorComputation(loaderDeps.page + loaderDeps.filter.length), - }) - - return runPerfSelectorComputation(loaderDeps())} /> -} - -function SearchLoaderDataSubscriber() { - const loaderData = searchRoute.useLoaderData({ - select: (loaderData) => - runPerfSelectorComputation(loaderData.seed + loaderData.checksum), - }) - - return runPerfSelectorComputation(loaderData())} /> -} - -function ContextParamsSubscriber() { - const params = contextRoute.useParams({ - select: (params) => runPerfSelectorComputation(Number(params.id)), - }) - - return runPerfSelectorComputation(params())} /> -} - -function ContextRouteSubscriber() { - const context = contextRoute.useRouteContext({ - select: (context) => runPerfSelectorComputation(context.sectionSeed), - }) - - return runPerfSelectorComputation(context())} /> -} - -function ItemsPage() { - return ( - <> - {() => } - - Details - - - Preserve search on item - - - - ) -} - -function ItemDetailsPage() { - return ( - <> - {() => } - - Back to item - - - ) -} - -function SearchPage() { - return ( - <> - {() => } - {() => } - {() => } - ({ - page: prev.page + 1, - filter: prev.filter, - junk: 'local-updater', - })} - activeOptions={{ includeSearch: true }} - activeProps={{ class: 'active-link' }} - inactiveProps={{ class: 'inactive-link' }} - > - Next page - - - ) -} - -function ContextPage() { - return ( - <> - {() => } - {() => } - - ) -} - -export function mountTestApp(container: Element) { - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/items/0'], - }), - scrollRestoration: true, - routeTree: rootRoute.addChildren([ - itemsRoute.addChildren([itemDetailsRoute]), - searchRoute, - contextRoute, - ]), - }) - - const unmount = render(() => , container) - - return { - router, - unmount, - } -} diff --git a/benchmarks/client-nav/solid/setup.ts b/benchmarks/client-nav/solid/setup.ts index 9b532a466a..aa6eed2a25 100644 --- a/benchmarks/client-nav/solid/setup.ts +++ b/benchmarks/client-nav/solid/setup.ts @@ -1,6 +1,8 @@ -import type { NavigateOptions } from '@tanstack/router-core' -import type * as App from './app' -import { getRequiredLink, waitForRequiredLink } from '../setup-helpers' +import type * as App from './src/app' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' const appModulePath = './dist/app.js' const { mountTestApp } = (await import( @@ -8,105 +10,76 @@ const { mountTestApp } = (await import( )) as typeof App export function setup() { - if (process.env.NODE_ENV !== 'production') { - console.warn( - 'client-nav benchmark is running without NODE_ENV=production; Solid dev overhead will dominate results.', - ) - } + warnClientNavDevMode('solid') - let container: HTMLDivElement | undefined = undefined - let unmount: (() => void) | undefined = undefined - let unsub = () => {} + const lifecycle = createClientNavLifecycle({ mountTestApp }) let stepIndex = 0 - let next: () => Promise = () => Promise.reject('Test not initialized') + + const steps = [ + () => lifecycle.click('go-items-1'), + () => lifecycle.click('items-details'), + () => + lifecycle.navigate({ + to: '/items/$id/details', + params: { id: 2 }, + replace: true, + }), + () => lifecycle.click('items-parent'), + () => lifecycle.click('go-search'), + () => lifecycle.click('search-next-page'), + () => + lifecycle.navigate({ + to: '/search', + search: { page: 1, filter: 'all' }, + replace: true, + }), + () => lifecycle.click('go-ctx'), + () => + lifecycle.navigate({ + to: '/ctx/$id', + params: { id: 2 }, + replace: true, + }), + () => lifecycle.click('go-items-2'), + ] as const + + async function prepareLinks() { + for (const testId of ['go-items-1', 'go-items-2', 'go-search', 'go-ctx']) { + await lifecycle.waitForLink(testId) + } + } async function before() { stepIndex = 0 - container = document.createElement('div') - document.body.append(container) - - const { router, unmount: dispose } = mountTestApp(container) - unmount = dispose - - let resolveRendered: () => void = () => {} - unsub = router.subscribe('onRendered', () => { - resolveRendered() - }) - - const navigate = (opts: NavigateOptions) => - new Promise((resolveNext) => { - resolveRendered = resolveNext - router.navigate(opts) - }) + await lifecycle.before() + await prepareLinks() + } - const click = (testId: string, cache?: Map) => - new Promise((resolveNext) => { - resolveRendered = resolveNext + async function sanity() { + await lifecycle.before() - getRequiredLink(container!, testId, cache).dispatchEvent( - new MouseEvent('click', { - bubbles: true, - cancelable: true, - button: 0, - }), - ) + try { + await lifecycle.navigate({ + to: '/search', + search: { page: 1, filter: 'all' }, + replace: true, }) - - await router.load() - - const cachedLinks = new Map() - for (const testId of ['go-items-1', 'go-items-2', 'go-search', 'go-ctx']) { - await waitForRequiredLink(container, testId, cachedLinks) + await lifecycle.waitForLink('search-next-page') + } finally { + await lifecycle.after() } - - const steps = [ - () => click('go-items-1', cachedLinks), - () => click('items-details'), - () => - navigate({ - to: '/items/$id/details', - params: { id: 2 }, - replace: true, - }), - () => click('items-parent'), - () => click('go-search', cachedLinks), - () => click('search-next-page'), - () => - navigate({ - to: '/search', - search: { page: 1, filter: 'all' }, - replace: true, - }), - () => click('go-ctx', cachedLinks), - () => - navigate({ - to: '/ctx/$id', - params: { id: 2 }, - replace: true, - }), - () => click('go-items-2', cachedLinks), - ] as const - - next = () => { - const step = steps[stepIndex % steps.length]! - stepIndex += 1 - return step() - } - } - - function after() { - unmount?.() - container?.remove() - unsub() } function tick() { - return next() + const step = steps[stepIndex % steps.length]! + stepIndex += 1 + return step() } return { before, + sanity, tick, - after, + after: lifecycle.after, } } diff --git a/benchmarks/client-nav/solid/speed.bench.ts b/benchmarks/client-nav/solid/speed.bench.ts index 34791180ce..a8381ce3db 100644 --- a/benchmarks/client-nav/solid/speed.bench.ts +++ b/benchmarks/client-nav/solid/speed.bench.ts @@ -1,9 +1,11 @@ import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' import { setup } from './setup' -describe('client-nav', () => { - const test = setup() +const test = setup() +await test.sanity() +describe('client-nav', () => { /** * Running `vitest bench` ignores "suite hooks" like `beforeAll` and `afterAll`, * so we use tinybench's `setup` and `teardown` options to run our setup and teardown logic. @@ -25,8 +27,7 @@ describe('client-nav', () => { } }, { - warmupIterations: 100, - time: 10_000, + ...clientNavBenchOptions, setup: test.before, teardown: test.after, }, diff --git a/benchmarks/client-nav/solid/speed.flame.ts b/benchmarks/client-nav/solid/speed.flame.ts index 285ccc63d7..dc36e0ef34 100644 --- a/benchmarks/client-nav/solid/speed.flame.ts +++ b/benchmarks/client-nav/solid/speed.flame.ts @@ -4,6 +4,7 @@ import { setup } from './setup.ts' const DURATION_MS = 10_000 const test = setup() +await test.sanity() try { await test.before() @@ -13,6 +14,6 @@ try { await test.tick() } } finally { - test.after() + await test.after() window.close() } diff --git a/benchmarks/client-nav/solid/src/app.tsx b/benchmarks/client-nav/solid/src/app.tsx new file mode 100644 index 0000000000..f7d1a6188b --- /dev/null +++ b/benchmarks/client-nav/solid/src/app.tsx @@ -0,0 +1,13 @@ +import { RouterProvider } from '@tanstack/solid-router' +import { render } from 'solid-js/web' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const unmount = render(() => , container) + + return { + router, + unmount, + } +} diff --git a/benchmarks/client-nav/solid/src/routeTree.gen.ts b/benchmarks/client-nav/solid/src/routeTree.gen.ts new file mode 100644 index 0000000000..464bae5452 --- /dev/null +++ b/benchmarks/client-nav/solid/src/routeTree.gen.ts @@ -0,0 +1,123 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SearchRouteImport } from './routes/search' +import { Route as ItemsIdRouteImport } from './routes/items.$id' +import { Route as CtxIdRouteImport } from './routes/ctx.$id' +import { Route as ItemsIdDetailsRouteImport } from './routes/items.$id.details' + +const SearchRoute = SearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => rootRouteImport, +} as any) +const ItemsIdRoute = ItemsIdRouteImport.update({ + id: '/items/$id', + path: '/items/$id', + getParentRoute: () => rootRouteImport, +} as any) +const CtxIdRoute = CtxIdRouteImport.update({ + id: '/ctx/$id', + path: '/ctx/$id', + getParentRoute: () => rootRouteImport, +} as any) +const ItemsIdDetailsRoute = ItemsIdDetailsRouteImport.update({ + id: '/details', + path: '/details', + getParentRoute: () => ItemsIdRoute, +} as any) + +export interface FileRoutesByFullPath { + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRoutesByTo { + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + fileRoutesByTo: FileRoutesByTo + to: '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + id: '__root__' | '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + ItemsIdRoute: typeof ItemsIdRouteWithChildren + SearchRoute: typeof SearchRoute + CtxIdRoute: typeof CtxIdRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/search': { + id: '/search' + path: '/search' + fullPath: '/search' + preLoaderRoute: typeof SearchRouteImport + parentRoute: typeof rootRouteImport + } + '/items/$id': { + id: '/items/$id' + path: '/items/$id' + fullPath: '/items/$id' + preLoaderRoute: typeof ItemsIdRouteImport + parentRoute: typeof rootRouteImport + } + '/ctx/$id': { + id: '/ctx/$id' + path: '/ctx/$id' + fullPath: '/ctx/$id' + preLoaderRoute: typeof CtxIdRouteImport + parentRoute: typeof rootRouteImport + } + '/items/$id/details': { + id: '/items/$id/details' + path: '/details' + fullPath: '/items/$id/details' + preLoaderRoute: typeof ItemsIdDetailsRouteImport + parentRoute: typeof ItemsIdRoute + } + } +} + +interface ItemsIdRouteChildren { + ItemsIdDetailsRoute: typeof ItemsIdDetailsRoute +} + +const ItemsIdRouteChildren: ItemsIdRouteChildren = { + ItemsIdDetailsRoute: ItemsIdDetailsRoute, +} + +const ItemsIdRouteWithChildren = ItemsIdRoute._addFileChildren( + ItemsIdRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + ItemsIdRoute: ItemsIdRouteWithChildren, + SearchRoute: SearchRoute, + CtxIdRoute: CtxIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/solid/src/router.tsx b/benchmarks/client-nav/solid/src/router.tsx new file mode 100644 index 0000000000..587c1e157f --- /dev/null +++ b/benchmarks/client-nav/solid/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: ['/items/0'], + }), + scrollRestoration: true, + routeTree, + }) +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/solid/src/routes/__root.tsx b/benchmarks/client-nav/solid/src/routes/__root.tsx new file mode 100644 index 0000000000..21618890bf --- /dev/null +++ b/benchmarks/client-nav/solid/src/routes/__root.tsx @@ -0,0 +1,125 @@ +import { For } from 'solid-js' +import { + Link, + Outlet, + createRootRoute, + useParams, + useSearch, +} from '@tanstack/solid-router' +import { + PerfValue, + linkGroups, + rootSelectors, + runPerfSelectorComputation, +} from '../shared' +import { Route as SearchRoute } from './search' + +export const Route = createRootRoute({ + component: Root, +}) + +function RootParamsSubscriber() { + const params = useParams({ + strict: false, + select: (params) => runPerfSelectorComputation(Number(params.id ?? 0)), + }) + + return runPerfSelectorComputation(params())} /> +} + +function RootSearchSubscriber() { + const search = useSearch({ + strict: false, + select: (search) => runPerfSelectorComputation(Number(search.page ?? 0)), + }) + + return runPerfSelectorComputation(search())} /> +} + +function LinkPanel() { + return ( + <> + + {(groupIndex) => { + const itemsId = groupIndex === 0 ? 1 : groupIndex + 2 + const ctxId = groupIndex + 1 + + return ( +
+ + {`Items ${itemsId}`} + + + {`Items 2 alt ${groupIndex}`} + + + {`Search ${groupIndex}`} + + + {`Context ${ctxId}`} + + ({ + page: prev.page + groupIndex + 1, + filter: prev.filter, + junk: `updater-${groupIndex}`, + })} + activeOptions={{ includeSearch: true }} + > + {({ isActive }) => + isActive + ? `Search updater active ${groupIndex}` + : `Search updater inactive ${groupIndex}` + } + +
+ ) + }} +
+ + ) +} + +function Root() { + return ( + <> + {() => } + {() => } + + + + ) +} diff --git a/benchmarks/client-nav/solid/src/routes/ctx.$id.tsx b/benchmarks/client-nav/solid/src/routes/ctx.$id.tsx new file mode 100644 index 0000000000..58544319cb --- /dev/null +++ b/benchmarks/client-nav/solid/src/routes/ctx.$id.tsx @@ -0,0 +1,39 @@ +import { For } from 'solid-js' +import { createFileRoute } from '@tanstack/solid-router' +import { + PerfValue, + routeSelectors, + runPerfSelectorComputation, +} from '../shared' + +export const Route = createFileRoute('/ctx/$id')({ + beforeLoad: ({ params }) => ({ + sectionSeed: Number(params.id) * 13 + 1, + }), + component: ContextPage, +}) + +function ContextParamsSubscriber() { + const params = Route.useParams({ + select: (params) => runPerfSelectorComputation(Number(params.id)), + }) + + return runPerfSelectorComputation(params())} /> +} + +function ContextRouteSubscriber() { + const context = Route.useRouteContext({ + select: (context) => runPerfSelectorComputation(context.sectionSeed), + }) + + return runPerfSelectorComputation(context())} /> +} + +function ContextPage() { + return ( + <> + {() => } + {() => } + + ) +} diff --git a/benchmarks/client-nav/solid/src/routes/items.$id.details.tsx b/benchmarks/client-nav/solid/src/routes/items.$id.details.tsx new file mode 100644 index 0000000000..501cd908be --- /dev/null +++ b/benchmarks/client-nav/solid/src/routes/items.$id.details.tsx @@ -0,0 +1,25 @@ +import { For } from 'solid-js' +import { Link, createFileRoute } from '@tanstack/solid-router' +import { routeSelectors } from '../shared' +import { ItemParamsSubscriber } from './items.$id' + +export const Route = createFileRoute('/items/$id/details')({ + component: ItemDetailsPage, +}) + +function ItemDetailsPage() { + return ( + <> + {() => } + + Back to item + + + ) +} diff --git a/benchmarks/client-nav/solid/src/routes/items.$id.tsx b/benchmarks/client-nav/solid/src/routes/items.$id.tsx new file mode 100644 index 0000000000..06c69987fc --- /dev/null +++ b/benchmarks/client-nav/solid/src/routes/items.$id.tsx @@ -0,0 +1,59 @@ +import { For } from 'solid-js' +import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' +import { + PerfValue, + noop, + normalizePage, + routeSelectors, + runPerfSelectorComputation, +} from '../shared' + +export const Route = createFileRoute('/items/$id')({ + params: { + parse: (params) => ({ + ...params, + id: normalizePage(params.id), + }), + stringify: (params) => ({ + ...params, + id: `${params.id}`, + }), + }, + onEnter: noop, + onStay: noop, + onLeave: noop, + component: ItemsPage, +}) + +export function ItemParamsSubscriber() { + const params = Route.useParams({ + select: (params) => runPerfSelectorComputation(params.id), + }) + + return runPerfSelectorComputation(params())} /> +} + +function ItemsPage() { + return ( + <> + {() => } + + Details + + + Preserve search on item + + + + ) +} diff --git a/benchmarks/client-nav/solid/src/routes/search.tsx b/benchmarks/client-nav/solid/src/routes/search.tsx new file mode 100644 index 0000000000..4eaee67ce5 --- /dev/null +++ b/benchmarks/client-nav/solid/src/routes/search.tsx @@ -0,0 +1,91 @@ +import { For } from 'solid-js' +import { Link, createFileRoute } from '@tanstack/solid-router' +import { + PerfValue, + normalizeFilter, + normalizePage, + routeSelectors, + runPerfSelectorComputation, +} from '../shared' + +export const Route = createFileRoute('/search')({ + validateSearch: (search: Record) => ({ + page: normalizePage(search.page), + filter: normalizeFilter(search.filter), + }), + search: { + middlewares: [ + ({ search, next }) => { + const result = next(search) + return { + page: result.page, + filter: result.filter, + } + }, + ], + }, + loaderDeps: ({ search }) => ({ + page: search.page, + filter: search.filter, + }), + loader: ({ deps }) => ({ + seed: deps.page * 31 + deps.filter.length, + checksum: deps.page * 17 + deps.filter.length, + }), + staleTime: 60_000, + gcTime: 60_000, + component: SearchPage, +}) + +function SearchStateSubscriber() { + const search = Route.useSearch({ + select: (search) => + runPerfSelectorComputation(search.page + search.filter.length), + }) + + return runPerfSelectorComputation(search())} /> +} + +function SearchLoaderDepsSubscriber() { + const loaderDeps = Route.useLoaderDeps({ + select: (loaderDeps) => + runPerfSelectorComputation(loaderDeps.page + loaderDeps.filter.length), + }) + + return runPerfSelectorComputation(loaderDeps())} /> +} + +function SearchLoaderDataSubscriber() { + const loaderData = Route.useLoaderData({ + select: (loaderData) => + runPerfSelectorComputation(loaderData.seed + loaderData.checksum), + }) + + return runPerfSelectorComputation(loaderData())} /> +} + +function SearchPage() { + return ( + <> + {() => } + {() => } + {() => } + ({ + page: prev.page + 1, + filter: prev.filter, + junk: 'local-updater', + })} + activeOptions={{ includeSearch: true }} + activeProps={{ class: 'active-link' }} + inactiveProps={{ class: 'inactive-link' }} + > + Next page + + + ) +} diff --git a/benchmarks/client-nav/solid/src/shared.tsx b/benchmarks/client-nav/solid/src/shared.tsx new file mode 100644 index 0000000000..c3b43b4493 --- /dev/null +++ b/benchmarks/client-nav/solid/src/shared.tsx @@ -0,0 +1,33 @@ +import { createRenderEffect } from 'solid-js' + +export function runPerfSelectorComputation(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 40; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function normalizePage(value: unknown) { + const page = Number(value) + return Number.isFinite(page) && page > 0 ? Math.trunc(page) : 1 +} + +export function normalizeFilter(value: unknown) { + return typeof value === 'string' && value.length > 0 ? value : 'all' +} + +export function PerfValue(props: { value: () => number }) { + createRenderEffect(() => { + void props.value() + }) + + return null +} + +export const noop = () => {} +export const rootSelectors = Array.from({ length: 10 }, (_, index) => index) +export const routeSelectors = Array.from({ length: 6 }, (_, index) => index) +export const linkGroups = Array.from({ length: 4 }, (_, index) => index) diff --git a/benchmarks/client-nav/solid/tsconfig.json b/benchmarks/client-nav/solid/tsconfig.json index 4c500d0793..95d1bcef7c 100644 --- a/benchmarks/client-nav/solid/tsconfig.json +++ b/benchmarks/client-nav/solid/tsconfig.json @@ -7,6 +7,8 @@ "types": ["node", "vite/client", "vitest/globals"] }, "include": [ + "src/**/*.ts", + "src/**/*.tsx", "speed.bench.ts", "speed.flame.ts", "../jsdom.ts", diff --git a/benchmarks/client-nav/solid/vite.config.ts b/benchmarks/client-nav/solid/vite.config.ts index 24db8fe745..11504109d2 100644 --- a/benchmarks/client-nav/solid/vite.config.ts +++ b/benchmarks/client-nav/solid/vite.config.ts @@ -1,7 +1,10 @@ +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' import solid from 'vite-plugin-solid' import codspeedPlugin from '@codspeed/vitest-plugin' +const setupFile = fileURLToPath(new URL('../vitest.setup.ts', import.meta.url)) + export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify('production'), @@ -16,7 +19,7 @@ export default defineConfig({ emptyOutDir: true, minify: false, lib: { - entry: './solid/app.tsx', + entry: './solid/src/app.tsx', formats: ['es'], fileName: 'app', }, @@ -28,7 +31,7 @@ export default defineConfig({ name: '@benchmarks/client-nav (solid)', watch: false, environment: 'jsdom', - setupFiles: ['./vitest.setup.ts'], + setupFiles: [setupFile], server: { deps: { inline: [/@solidjs/, /@tanstack\/solid-store/], diff --git a/benchmarks/client-nav/tsconfig.json b/benchmarks/client-nav/tsconfig.json index f657f504d4..a3e1728ada 100644 --- a/benchmarks/client-nav/tsconfig.json +++ b/benchmarks/client-nav/tsconfig.json @@ -3,5 +3,16 @@ "compilerOptions": { "types": ["node", "vite/client", "vitest/globals"] }, - "include": ["vitest.setup.ts"] + "include": [ + "bench-utils.ts", + "benchmark.ts", + "jsdom.ts", + "lifecycle.ts", + "setup-helpers.ts", + "vitest.config.ts", + "vitest.react.config.ts", + "vitest.setup.ts", + "vitest.solid.config.ts", + "vitest.vue.config.ts" + ] } diff --git a/benchmarks/client-nav/vitest.config.ts b/benchmarks/client-nav/vitest.config.ts index 14776452ed..d880451f47 100644 --- a/benchmarks/client-nav/vitest.config.ts +++ b/benchmarks/client-nav/vitest.config.ts @@ -3,10 +3,59 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { watch: false, + fileParallelism: false, projects: [ './react/vite.config.ts', + './scenarios/route-matching/react/vite.config.ts', + './scenarios/location-building-links/react/vite.config.ts', + './scenarios/search-params/react/vite.config.ts', + './scenarios/before-load-context/react/vite.config.ts', + './scenarios/loader-cache/react/vite.config.ts', + './scenarios/preloading/react/vite.config.ts', + './scenarios/subscribers-selectors/react/vite.config.ts', + './scenarios/outlets-remounts/react/vite.config.ts', + './scenarios/control-flow/react/vite.config.ts', + './scenarios/interrupted-navigations/react/vite.config.ts', + './scenarios/scroll-restoration/react/vite.config.ts', + './scenarios/masking-rewrites/react/vite.config.ts', + './scenarios/head-management/react/vite.config.ts', + './scenarios/deferred-await/react/vite.config.ts', + './scenarios/history-events-blockers/react/vite.config.ts', + './scenarios/hydration-resume/react/vite.config.ts', './solid/vite.config.ts', + './scenarios/route-matching/solid/vite.config.ts', + './scenarios/location-building-links/solid/vite.config.ts', + './scenarios/search-params/solid/vite.config.ts', + './scenarios/before-load-context/solid/vite.config.ts', + './scenarios/loader-cache/solid/vite.config.ts', + './scenarios/preloading/solid/vite.config.ts', + './scenarios/subscribers-selectors/solid/vite.config.ts', + './scenarios/outlets-remounts/solid/vite.config.ts', + './scenarios/control-flow/solid/vite.config.ts', + './scenarios/interrupted-navigations/solid/vite.config.ts', + './scenarios/scroll-restoration/solid/vite.config.ts', + './scenarios/masking-rewrites/solid/vite.config.ts', + './scenarios/head-management/solid/vite.config.ts', + './scenarios/deferred-await/solid/vite.config.ts', + './scenarios/history-events-blockers/solid/vite.config.ts', + './scenarios/hydration-resume/solid/vite.config.ts', './vue/vite.config.ts', + './scenarios/route-matching/vue/vite.config.ts', + './scenarios/location-building-links/vue/vite.config.ts', + './scenarios/search-params/vue/vite.config.ts', + './scenarios/before-load-context/vue/vite.config.ts', + './scenarios/loader-cache/vue/vite.config.ts', + './scenarios/preloading/vue/vite.config.ts', + './scenarios/subscribers-selectors/vue/vite.config.ts', + './scenarios/outlets-remounts/vue/vite.config.ts', + './scenarios/control-flow/vue/vite.config.ts', + './scenarios/interrupted-navigations/vue/vite.config.ts', + './scenarios/scroll-restoration/vue/vite.config.ts', + './scenarios/masking-rewrites/vue/vite.config.ts', + './scenarios/head-management/vue/vite.config.ts', + './scenarios/deferred-await/vue/vite.config.ts', + './scenarios/history-events-blockers/vue/vite.config.ts', + './scenarios/hydration-resume/vue/vite.config.ts', ], }, }) diff --git a/benchmarks/client-nav/vitest.react.config.ts b/benchmarks/client-nav/vitest.react.config.ts new file mode 100644 index 0000000000..b0729af753 --- /dev/null +++ b/benchmarks/client-nav/vitest.react.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + watch: false, + fileParallelism: false, + projects: [ + './react/vite.config.ts', + './scenarios/route-matching/react/vite.config.ts', + './scenarios/location-building-links/react/vite.config.ts', + './scenarios/search-params/react/vite.config.ts', + './scenarios/before-load-context/react/vite.config.ts', + './scenarios/loader-cache/react/vite.config.ts', + './scenarios/preloading/react/vite.config.ts', + './scenarios/subscribers-selectors/react/vite.config.ts', + './scenarios/outlets-remounts/react/vite.config.ts', + './scenarios/control-flow/react/vite.config.ts', + './scenarios/interrupted-navigations/react/vite.config.ts', + './scenarios/scroll-restoration/react/vite.config.ts', + './scenarios/masking-rewrites/react/vite.config.ts', + './scenarios/head-management/react/vite.config.ts', + './scenarios/deferred-await/react/vite.config.ts', + './scenarios/history-events-blockers/react/vite.config.ts', + './scenarios/hydration-resume/react/vite.config.ts', + ], + }, +}) diff --git a/benchmarks/client-nav/vitest.solid.config.ts b/benchmarks/client-nav/vitest.solid.config.ts new file mode 100644 index 0000000000..08ebc9e8a4 --- /dev/null +++ b/benchmarks/client-nav/vitest.solid.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + watch: false, + fileParallelism: false, + projects: [ + './solid/vite.config.ts', + './scenarios/route-matching/solid/vite.config.ts', + './scenarios/location-building-links/solid/vite.config.ts', + './scenarios/search-params/solid/vite.config.ts', + './scenarios/before-load-context/solid/vite.config.ts', + './scenarios/loader-cache/solid/vite.config.ts', + './scenarios/preloading/solid/vite.config.ts', + './scenarios/subscribers-selectors/solid/vite.config.ts', + './scenarios/outlets-remounts/solid/vite.config.ts', + './scenarios/control-flow/solid/vite.config.ts', + './scenarios/interrupted-navigations/solid/vite.config.ts', + './scenarios/scroll-restoration/solid/vite.config.ts', + './scenarios/masking-rewrites/solid/vite.config.ts', + './scenarios/head-management/solid/vite.config.ts', + './scenarios/deferred-await/solid/vite.config.ts', + './scenarios/history-events-blockers/solid/vite.config.ts', + './scenarios/hydration-resume/solid/vite.config.ts', + ], + }, +}) diff --git a/benchmarks/client-nav/vitest.vue.config.ts b/benchmarks/client-nav/vitest.vue.config.ts new file mode 100644 index 0000000000..c711b21bee --- /dev/null +++ b/benchmarks/client-nav/vitest.vue.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + watch: false, + fileParallelism: false, + projects: [ + './vue/vite.config.ts', + './scenarios/route-matching/vue/vite.config.ts', + './scenarios/location-building-links/vue/vite.config.ts', + './scenarios/search-params/vue/vite.config.ts', + './scenarios/before-load-context/vue/vite.config.ts', + './scenarios/loader-cache/vue/vite.config.ts', + './scenarios/preloading/vue/vite.config.ts', + './scenarios/subscribers-selectors/vue/vite.config.ts', + './scenarios/outlets-remounts/vue/vite.config.ts', + './scenarios/control-flow/vue/vite.config.ts', + './scenarios/interrupted-navigations/vue/vite.config.ts', + './scenarios/scroll-restoration/vue/vite.config.ts', + './scenarios/masking-rewrites/vue/vite.config.ts', + './scenarios/head-management/vue/vite.config.ts', + './scenarios/deferred-await/vue/vite.config.ts', + './scenarios/history-events-blockers/vue/vite.config.ts', + './scenarios/hydration-resume/vue/vite.config.ts', + ], + }, +}) diff --git a/benchmarks/client-nav/vue/app.tsx b/benchmarks/client-nav/vue/app.tsx deleted file mode 100644 index f88eee8129..0000000000 --- a/benchmarks/client-nav/vue/app.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import * as Vue from 'vue' -import { - Link, - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - useParams, - useSearch, -} from '@tanstack/vue-router' - -function runPerfSelectorComputation(seed: number) { - let value = Math.trunc(seed) | 0 - - for (let index = 0; index < 40; index++) { - value = (value * 1664525 + 1013904223 + index) >>> 0 - } - - return value -} - -function normalizePage(value: unknown) { - const page = Number(value) - return Number.isFinite(page) && page > 0 ? Math.trunc(page) : 1 -} - -function normalizeFilter(value: unknown) { - return typeof value === 'string' && value.length > 0 ? value : 'all' -} - -const noop = () => {} -const rootSelectors = Array.from({ length: 10 }, (_, index) => index) -const routeSelectors = Array.from({ length: 6 }, (_, index) => index) -const linkGroups = Array.from({ length: 4 }, (_, index) => index) - -const RootParamsSubscriber = Vue.defineComponent({ - setup() { - const params = useParams({ - strict: false, - select: (params) => runPerfSelectorComputation(Number(params.id ?? 0)), - }) - - return () => { - void runPerfSelectorComputation(params.value) - return null - } - }, -}) - -const RootSearchSubscriber = Vue.defineComponent({ - setup() { - const search = useSearch({ - strict: false, - select: (search) => runPerfSelectorComputation(Number(search.page ?? 0)), - }) - - return () => { - void runPerfSelectorComputation(search.value) - return null - } - }, -}) - -const LinkPanel = Vue.defineComponent({ - setup() { - return () => ( - <> - {linkGroups.map((groupIndex) => { - const itemsId = groupIndex === 0 ? 1 : groupIndex + 2 - const ctxId = groupIndex + 1 - - return ( -
- - {`Items ${itemsId}`} - - - {`Items 2 alt ${groupIndex}`} - - - {`Search ${groupIndex}`} - - - {`Context ${ctxId}`} - - ({ - page: prev.page + groupIndex + 1, - filter: prev.filter, - junk: `updater-${groupIndex}`, - })} - activeOptions={{ includeSearch: true }} - > - {({ isActive }: { isActive: boolean }) => - isActive - ? `Search updater active ${groupIndex}` - : `Search updater inactive ${groupIndex}` - } - -
- ) - })} - - ) - }, -}) - -const Root = Vue.defineComponent({ - setup() { - return () => ( - <> - {rootSelectors.map((selector) => ( - - ))} - {rootSelectors.map((selector) => ( - - ))} - - - - ) - }, -}) - -const rootRoute = createRootRoute({ - component: Root, -}) - -const ItemParamsSubscriber = Vue.defineComponent({ - setup() { - const params = itemsRoute.useParams({ - select: (params) => runPerfSelectorComputation(params.id), - }) - - return () => { - void runPerfSelectorComputation(params.value) - return null - } - }, -}) - -const SearchStateSubscriber = Vue.defineComponent({ - setup() { - const search = searchRoute.useSearch({ - select: (search) => - runPerfSelectorComputation(search.page + search.filter.length), - }) - - return () => { - void runPerfSelectorComputation(search.value) - return null - } - }, -}) - -const SearchLoaderDepsSubscriber = Vue.defineComponent({ - setup() { - const loaderDeps = searchRoute.useLoaderDeps({ - select: (loaderDeps) => - runPerfSelectorComputation(loaderDeps.page + loaderDeps.filter.length), - }) - - return () => { - void runPerfSelectorComputation(loaderDeps.value) - return null - } - }, -}) - -const SearchLoaderDataSubscriber = Vue.defineComponent({ - setup() { - const loaderData = searchRoute.useLoaderData({ - select: (loaderData) => - runPerfSelectorComputation(loaderData.seed + loaderData.checksum), - }) - - return () => { - void runPerfSelectorComputation(loaderData.value) - return null - } - }, -}) - -const ContextParamsSubscriber = Vue.defineComponent({ - setup() { - const params = contextRoute.useParams({ - select: (params) => runPerfSelectorComputation(Number(params.id)), - }) - - return () => { - void runPerfSelectorComputation(params.value) - return null - } - }, -}) - -const ContextRouteSubscriber = Vue.defineComponent({ - setup() { - const context = contextRoute.useRouteContext({ - select: (context) => runPerfSelectorComputation(context.sectionSeed), - }) - - return () => { - void runPerfSelectorComputation(context.value) - return null - } - }, -}) - -const ItemsPage = Vue.defineComponent({ - setup() { - return () => ( - <> - {routeSelectors.map((selector) => ( - - ))} - - Details - - - Preserve search on item - - - - ) - }, -}) - -const ItemDetailsPage = Vue.defineComponent({ - setup() { - return () => ( - <> - {routeSelectors.map((selector) => ( - - ))} - - Back to item - - - ) - }, -}) - -const SearchPage = Vue.defineComponent({ - setup() { - return () => ( - <> - {routeSelectors.map((selector) => ( - - ))} - {routeSelectors.map((selector) => ( - - ))} - {routeSelectors.map((selector) => ( - - ))} - ({ - page: prev.page + 1, - filter: prev.filter, - junk: 'local-updater', - })} - activeOptions={{ includeSearch: true }} - activeProps={{ class: 'active-link' }} - inactiveProps={{ class: 'inactive-link' }} - > - Next page - - - ) - }, -}) - -const ContextPage = Vue.defineComponent({ - setup() { - return () => ( - <> - {routeSelectors.map((selector) => ( - - ))} - {routeSelectors.map((selector) => ( - - ))} - - ) - }, -}) - -const itemsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/items/$id', - params: { - parse: (params) => ({ - ...params, - id: normalizePage(params.id), - }), - stringify: (params) => ({ - ...params, - id: `${params.id}`, - }), - }, - onEnter: noop, - onStay: noop, - onLeave: noop, - component: ItemsPage, -}) - -const itemDetailsRoute = createRoute({ - getParentRoute: () => itemsRoute, - path: 'details', - component: ItemDetailsPage, -}) - -const searchRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/search', - validateSearch: (search: Record) => ({ - page: normalizePage(search.page), - filter: normalizeFilter(search.filter), - }), - search: { - middlewares: [ - ({ search, next }) => { - const result = next(search) - return { - page: result.page, - filter: result.filter, - } - }, - ], - }, - loaderDeps: ({ search }) => ({ - page: search.page, - filter: search.filter, - }), - loader: ({ deps }) => ({ - seed: deps.page * 31 + deps.filter.length, - checksum: deps.page * 17 + deps.filter.length, - }), - staleTime: 60_000, - gcTime: 60_000, - component: SearchPage, -}) - -const contextRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/ctx/$id', - beforeLoad: ({ params }) => ({ - sectionSeed: Number(params.id) * 13 + 1, - }), - component: ContextPage, -}) - -export function mountTestApp(container: Element) { - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/items/0'], - }), - scrollRestoration: true, - routeTree: rootRoute.addChildren([ - itemsRoute.addChildren([itemDetailsRoute]), - searchRoute, - contextRoute, - ]), - }) - - const component = - const app = Vue.createApp({ - render: () => component, - }) - - app.mount(container) - - return { - router, - unmount() { - app.unmount() - }, - } -} diff --git a/benchmarks/client-nav/vue/setup.ts b/benchmarks/client-nav/vue/setup.ts index 75a633f629..3b3a657006 100644 --- a/benchmarks/client-nav/vue/setup.ts +++ b/benchmarks/client-nav/vue/setup.ts @@ -1,6 +1,8 @@ -import type { NavigateOptions } from '@tanstack/router-core' -import type * as App from './app' -import { getRequiredLink, waitForRequiredLink } from '../setup-helpers' +import type * as App from './src/app' +import { + createClientNavLifecycle, + warnClientNavDevMode, +} from '#client-nav/lifecycle' const appModulePath = './dist/app.js' const { mountTestApp } = (await import( @@ -8,105 +10,76 @@ const { mountTestApp } = (await import( )) as typeof App export function setup() { - if (process.env.NODE_ENV !== 'production') { - console.warn( - 'client-nav benchmark is running without NODE_ENV=production; Vue dev overhead will dominate results.', - ) - } + warnClientNavDevMode('vue') - let container: HTMLDivElement | undefined = undefined - let unmount: (() => void) | undefined = undefined - let unsub = () => {} + const lifecycle = createClientNavLifecycle({ mountTestApp }) let stepIndex = 0 - let next: () => Promise = () => Promise.reject('Test not initialized') + + const steps = [ + () => lifecycle.click('go-items-1'), + () => lifecycle.click('items-details'), + () => + lifecycle.navigate({ + to: '/items/$id/details', + params: { id: 2 }, + replace: true, + }), + () => lifecycle.click('items-parent'), + () => lifecycle.click('go-search'), + () => lifecycle.click('search-next-page'), + () => + lifecycle.navigate({ + to: '/search', + search: { page: 1, filter: 'all' }, + replace: true, + }), + () => lifecycle.click('go-ctx'), + () => + lifecycle.navigate({ + to: '/ctx/$id', + params: { id: 2 }, + replace: true, + }), + () => lifecycle.click('go-items-2'), + ] as const + + async function prepareLinks() { + for (const testId of ['go-items-1', 'go-items-2', 'go-search', 'go-ctx']) { + await lifecycle.waitForLink(testId) + } + } async function before() { stepIndex = 0 - container = document.createElement('div') - document.body.append(container) - - const { router, unmount: dispose } = mountTestApp(container) - unmount = dispose - - let resolveRendered: () => void = () => {} - unsub = router.subscribe('onRendered', () => { - resolveRendered() - }) - - const navigate = (opts: NavigateOptions) => - new Promise((resolveNext) => { - resolveRendered = resolveNext - router.navigate(opts) - }) + await lifecycle.before() + await prepareLinks() + } - const click = (testId: string, cache?: Map) => - new Promise((resolveNext) => { - resolveRendered = resolveNext + async function sanity() { + await lifecycle.before() - getRequiredLink(container!, testId, cache).dispatchEvent( - new MouseEvent('click', { - bubbles: true, - cancelable: true, - button: 0, - }), - ) + try { + await lifecycle.navigate({ + to: '/search', + search: { page: 1, filter: 'all' }, + replace: true, }) - - await router.load() - - const cachedLinks = new Map() - for (const testId of ['go-items-1', 'go-items-2', 'go-search', 'go-ctx']) { - await waitForRequiredLink(container, testId, cachedLinks) + await lifecycle.waitForLink('search-next-page') + } finally { + await lifecycle.after() } - - const steps = [ - () => click('go-items-1', cachedLinks), - () => click('items-details'), - () => - navigate({ - to: '/items/$id/details', - params: { id: 2 }, - replace: true, - }), - () => click('items-parent'), - () => click('go-search', cachedLinks), - () => click('search-next-page'), - () => - navigate({ - to: '/search', - search: { page: 1, filter: 'all' }, - replace: true, - }), - () => click('go-ctx', cachedLinks), - () => - navigate({ - to: '/ctx/$id', - params: { id: 2 }, - replace: true, - }), - () => click('go-items-2', cachedLinks), - ] as const - - next = () => { - const step = steps[stepIndex % steps.length]! - stepIndex += 1 - return step() - } - } - - function after() { - unmount?.() - container?.remove() - unsub() } function tick() { - return next() + const step = steps[stepIndex % steps.length]! + stepIndex += 1 + return step() } return { before, + sanity, tick, - after, + after: lifecycle.after, } } diff --git a/benchmarks/client-nav/vue/speed.bench.ts b/benchmarks/client-nav/vue/speed.bench.ts index f9e532f5ba..7d24176d99 100644 --- a/benchmarks/client-nav/vue/speed.bench.ts +++ b/benchmarks/client-nav/vue/speed.bench.ts @@ -1,9 +1,11 @@ import { afterAll, beforeAll, bench, describe } from 'vitest' +import { clientNavBenchOptions } from '#client-nav/bench-utils' import { setup } from './setup' -describe('client-nav', () => { - const test = setup() +const test = setup() +await test.sanity() +describe('client-nav', () => { /** * Running `vitest bench` ignores "suite hooks" like `beforeAll` and `afterAll`, * so we use tinybench's `setup` and `teardown` options to run our setup and teardown logic. @@ -25,8 +27,7 @@ describe('client-nav', () => { } }, { - warmupIterations: 100, - time: 10_000, + ...clientNavBenchOptions, setup: test.before, teardown: test.after, }, diff --git a/benchmarks/client-nav/vue/speed.flame.ts b/benchmarks/client-nav/vue/speed.flame.ts index 285ccc63d7..dc36e0ef34 100644 --- a/benchmarks/client-nav/vue/speed.flame.ts +++ b/benchmarks/client-nav/vue/speed.flame.ts @@ -4,6 +4,7 @@ import { setup } from './setup.ts' const DURATION_MS = 10_000 const test = setup() +await test.sanity() try { await test.before() @@ -13,6 +14,6 @@ try { await test.tick() } } finally { - test.after() + await test.after() window.close() } diff --git a/benchmarks/client-nav/vue/src/app.tsx b/benchmarks/client-nav/vue/src/app.tsx new file mode 100644 index 0000000000..c1b64412d0 --- /dev/null +++ b/benchmarks/client-nav/vue/src/app.tsx @@ -0,0 +1,19 @@ +import * as Vue from 'vue' +import { RouterProvider } from '@tanstack/vue-router' +import { getRouter } from './router' + +export function mountTestApp(container: Element) { + const router = getRouter() + const app = Vue.createApp({ + render: () => , + }) + + app.mount(container) + + return { + router, + unmount() { + app.unmount() + }, + } +} diff --git a/benchmarks/client-nav/vue/src/routeTree.gen.ts b/benchmarks/client-nav/vue/src/routeTree.gen.ts new file mode 100644 index 0000000000..c2b841647c --- /dev/null +++ b/benchmarks/client-nav/vue/src/routeTree.gen.ts @@ -0,0 +1,123 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SearchRouteImport } from './routes/search' +import { Route as ItemsIdRouteImport } from './routes/items.$id' +import { Route as CtxIdRouteImport } from './routes/ctx.$id' +import { Route as ItemsIdDetailsRouteImport } from './routes/items.$id.details' + +const SearchRoute = SearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => rootRouteImport, +} as any) +const ItemsIdRoute = ItemsIdRouteImport.update({ + id: '/items/$id', + path: '/items/$id', + getParentRoute: () => rootRouteImport, +} as any) +const CtxIdRoute = CtxIdRouteImport.update({ + id: '/ctx/$id', + path: '/ctx/$id', + getParentRoute: () => rootRouteImport, +} as any) +const ItemsIdDetailsRoute = ItemsIdDetailsRouteImport.update({ + id: '/details', + path: '/details', + getParentRoute: () => ItemsIdRoute, +} as any) + +export interface FileRoutesByFullPath { + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRoutesByTo { + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/items/$id': typeof ItemsIdRouteWithChildren + '/search': typeof SearchRoute + '/ctx/$id': typeof CtxIdRoute + '/items/$id/details': typeof ItemsIdDetailsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + fileRoutesByTo: FileRoutesByTo + to: '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + id: '__root__' | '/items/$id' | '/search' | '/ctx/$id' | '/items/$id/details' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + ItemsIdRoute: typeof ItemsIdRouteWithChildren + SearchRoute: typeof SearchRoute + CtxIdRoute: typeof CtxIdRoute +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/search': { + id: '/search' + path: '/search' + fullPath: '/search' + preLoaderRoute: typeof SearchRouteImport + parentRoute: typeof rootRouteImport + } + '/items/$id': { + id: '/items/$id' + path: '/items/$id' + fullPath: '/items/$id' + preLoaderRoute: typeof ItemsIdRouteImport + parentRoute: typeof rootRouteImport + } + '/ctx/$id': { + id: '/ctx/$id' + path: '/ctx/$id' + fullPath: '/ctx/$id' + preLoaderRoute: typeof CtxIdRouteImport + parentRoute: typeof rootRouteImport + } + '/items/$id/details': { + id: '/items/$id/details' + path: '/details' + fullPath: '/items/$id/details' + preLoaderRoute: typeof ItemsIdDetailsRouteImport + parentRoute: typeof ItemsIdRoute + } + } +} + +interface ItemsIdRouteChildren { + ItemsIdDetailsRoute: typeof ItemsIdDetailsRoute +} + +const ItemsIdRouteChildren: ItemsIdRouteChildren = { + ItemsIdDetailsRoute: ItemsIdDetailsRoute, +} + +const ItemsIdRouteWithChildren = ItemsIdRoute._addFileChildren( + ItemsIdRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + ItemsIdRoute: ItemsIdRouteWithChildren, + SearchRoute: SearchRoute, + CtxIdRoute: CtxIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/benchmarks/client-nav/vue/src/router.tsx b/benchmarks/client-nav/vue/src/router.tsx new file mode 100644 index 0000000000..69023a50f3 --- /dev/null +++ b/benchmarks/client-nav/vue/src/router.tsx @@ -0,0 +1,18 @@ +import { createMemoryHistory, createRouter } from '@tanstack/vue-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + history: createMemoryHistory({ + initialEntries: ['/items/0'], + }), + scrollRestoration: true, + routeTree, + }) +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/client-nav/vue/src/routes/__root.tsx b/benchmarks/client-nav/vue/src/routes/__root.tsx new file mode 100644 index 0000000000..1085c835ea --- /dev/null +++ b/benchmarks/client-nav/vue/src/routes/__root.tsx @@ -0,0 +1,140 @@ +import * as Vue from 'vue' +import { + Link, + Outlet, + createRootRoute, + useParams, + useSearch, +} from '@tanstack/vue-router' +import { + linkGroups, + rootSelectors, + runPerfSelectorComputation, +} from '../shared' +import { Route as SearchRoute } from './search' + +const RootParamsSubscriber = Vue.defineComponent({ + setup() { + const params = useParams({ + strict: false, + select: (params) => runPerfSelectorComputation(Number(params.id ?? 0)), + }) + + return () => { + void runPerfSelectorComputation(params.value) + return null + } + }, +}) + +const RootSearchSubscriber = Vue.defineComponent({ + setup() { + const search = useSearch({ + strict: false, + select: (search) => runPerfSelectorComputation(Number(search.page ?? 0)), + }) + + return () => { + void runPerfSelectorComputation(search.value) + return null + } + }, +}) + +const LinkPanel = Vue.defineComponent({ + setup() { + return () => ( + <> + {linkGroups.map((groupIndex) => { + const itemsId = groupIndex === 0 ? 1 : groupIndex + 2 + const ctxId = groupIndex + 1 + + return ( +
+ + {`Items ${itemsId}`} + + + {`Items 2 alt ${groupIndex}`} + + + {`Search ${groupIndex}`} + + + {`Context ${ctxId}`} + + ({ + page: prev.page + groupIndex + 1, + filter: prev.filter, + junk: `updater-${groupIndex}`, + })} + activeOptions={{ includeSearch: true }} + > + {({ isActive }: { isActive: boolean }) => + isActive + ? `Search updater active ${groupIndex}` + : `Search updater inactive ${groupIndex}` + } + +
+ ) + })} + + ) + }, +}) + +const Root = Vue.defineComponent({ + setup() { + return () => ( + <> + {rootSelectors.map((selector) => ( + + ))} + {rootSelectors.map((selector) => ( + + ))} + + + + ) + }, +}) + +export const Route = createRootRoute({ + component: Root, +}) diff --git a/benchmarks/client-nav/vue/src/routes/ctx.$id.tsx b/benchmarks/client-nav/vue/src/routes/ctx.$id.tsx new file mode 100644 index 0000000000..9c4549f204 --- /dev/null +++ b/benchmarks/client-nav/vue/src/routes/ctx.$id.tsx @@ -0,0 +1,51 @@ +import * as Vue from 'vue' +import { createFileRoute } from '@tanstack/vue-router' +import { routeSelectors, runPerfSelectorComputation } from '../shared' + +const ContextParamsSubscriber = Vue.defineComponent({ + setup() { + const params = Route.useParams({ + select: (params) => runPerfSelectorComputation(Number(params.id)), + }) + + return () => { + void runPerfSelectorComputation(params.value) + return null + } + }, +}) + +const ContextRouteSubscriber = Vue.defineComponent({ + setup() { + const context = Route.useRouteContext({ + select: (context) => runPerfSelectorComputation(context.sectionSeed), + }) + + return () => { + void runPerfSelectorComputation(context.value) + return null + } + }, +}) + +const ContextPage = Vue.defineComponent({ + setup() { + return () => ( + <> + {routeSelectors.map((selector) => ( + + ))} + {routeSelectors.map((selector) => ( + + ))} + + ) + }, +}) + +export const Route = createFileRoute('/ctx/$id')({ + beforeLoad: ({ params }) => ({ + sectionSeed: Number(params.id) * 13 + 1, + }), + component: ContextPage, +}) diff --git a/benchmarks/client-nav/vue/src/routes/items.$id.details.tsx b/benchmarks/client-nav/vue/src/routes/items.$id.details.tsx new file mode 100644 index 0000000000..2476795ede --- /dev/null +++ b/benchmarks/client-nav/vue/src/routes/items.$id.details.tsx @@ -0,0 +1,29 @@ +import * as Vue from 'vue' +import { Link, createFileRoute } from '@tanstack/vue-router' +import { routeSelectors } from '../shared' +import { ItemParamsSubscriber } from './items.$id' + +const ItemDetailsPage = Vue.defineComponent({ + setup() { + return () => ( + <> + {routeSelectors.map((selector) => ( + + ))} + + Back to item + + + ) + }, +}) + +export const Route = createFileRoute('/items/$id/details')({ + component: ItemDetailsPage, +}) diff --git a/benchmarks/client-nav/vue/src/routes/items.$id.tsx b/benchmarks/client-nav/vue/src/routes/items.$id.tsx new file mode 100644 index 0000000000..fb67e6b695 --- /dev/null +++ b/benchmarks/client-nav/vue/src/routes/items.$id.tsx @@ -0,0 +1,67 @@ +import * as Vue from 'vue' +import { Link, Outlet, createFileRoute } from '@tanstack/vue-router' +import { + noop, + normalizePage, + routeSelectors, + runPerfSelectorComputation, +} from '../shared' + +export const ItemParamsSubscriber = Vue.defineComponent({ + setup() { + const params = Route.useParams({ + select: (params) => runPerfSelectorComputation(params.id), + }) + + return () => { + void runPerfSelectorComputation(params.value) + return null + } + }, +}) + +const ItemsPage = Vue.defineComponent({ + setup() { + return () => ( + <> + {routeSelectors.map((selector) => ( + + ))} + + Details + + + Preserve search on item + + + + ) + }, +}) + +export const Route = createFileRoute('/items/$id')({ + params: { + parse: (params) => ({ + ...params, + id: normalizePage(params.id), + }), + stringify: (params) => ({ + ...params, + id: `${params.id}`, + }), + }, + onEnter: noop, + onStay: noop, + onLeave: noop, + component: ItemsPage, +}) diff --git a/benchmarks/client-nav/vue/src/routes/search.tsx b/benchmarks/client-nav/vue/src/routes/search.tsx new file mode 100644 index 0000000000..b2fe8dcc50 --- /dev/null +++ b/benchmarks/client-nav/vue/src/routes/search.tsx @@ -0,0 +1,113 @@ +import * as Vue from 'vue' +import { Link, createFileRoute } from '@tanstack/vue-router' +import { + normalizeFilter, + normalizePage, + routeSelectors, + runPerfSelectorComputation, +} from '../shared' + +const SearchStateSubscriber = Vue.defineComponent({ + setup() { + const search = Route.useSearch({ + select: (search) => + runPerfSelectorComputation(search.page + search.filter.length), + }) + + return () => { + void runPerfSelectorComputation(search.value) + return null + } + }, +}) + +const SearchLoaderDepsSubscriber = Vue.defineComponent({ + setup() { + const loaderDeps = Route.useLoaderDeps({ + select: (loaderDeps) => + runPerfSelectorComputation(loaderDeps.page + loaderDeps.filter.length), + }) + + return () => { + void runPerfSelectorComputation(loaderDeps.value) + return null + } + }, +}) + +const SearchLoaderDataSubscriber = Vue.defineComponent({ + setup() { + const loaderData = Route.useLoaderData({ + select: (loaderData) => + runPerfSelectorComputation(loaderData.seed + loaderData.checksum), + }) + + return () => { + void runPerfSelectorComputation(loaderData.value) + return null + } + }, +}) + +const SearchPage = Vue.defineComponent({ + setup() { + return () => ( + <> + {routeSelectors.map((selector) => ( + + ))} + {routeSelectors.map((selector) => ( + + ))} + {routeSelectors.map((selector) => ( + + ))} + ({ + page: prev.page + 1, + filter: prev.filter, + junk: 'local-updater', + })} + activeOptions={{ includeSearch: true }} + activeProps={{ class: 'active-link' }} + inactiveProps={{ class: 'inactive-link' }} + > + Next page + + + ) + }, +}) + +export const Route = createFileRoute('/search')({ + validateSearch: (search: Record) => ({ + page: normalizePage(search.page), + filter: normalizeFilter(search.filter), + }), + search: { + middlewares: [ + ({ search, next }) => { + const result = next(search) + return { + page: result.page, + filter: result.filter, + } + }, + ], + }, + loaderDeps: ({ search }) => ({ + page: search.page, + filter: search.filter, + }), + loader: ({ deps }) => ({ + seed: deps.page * 31 + deps.filter.length, + checksum: deps.page * 17 + deps.filter.length, + }), + staleTime: 60_000, + gcTime: 60_000, + component: SearchPage, +}) diff --git a/benchmarks/client-nav/vue/src/shared.ts b/benchmarks/client-nav/vue/src/shared.ts new file mode 100644 index 0000000000..25ba7891b3 --- /dev/null +++ b/benchmarks/client-nav/vue/src/shared.ts @@ -0,0 +1,23 @@ +export function runPerfSelectorComputation(seed: number) { + let value = Math.trunc(seed) | 0 + + for (let index = 0; index < 40; index++) { + value = (value * 1664525 + 1013904223 + index) >>> 0 + } + + return value +} + +export function normalizePage(value: unknown) { + const page = Number(value) + return Number.isFinite(page) && page > 0 ? Math.trunc(page) : 1 +} + +export function normalizeFilter(value: unknown) { + return typeof value === 'string' && value.length > 0 ? value : 'all' +} + +export const noop = () => {} +export const rootSelectors = Array.from({ length: 10 }, (_, index) => index) +export const routeSelectors = Array.from({ length: 6 }, (_, index) => index) +export const linkGroups = Array.from({ length: 4 }, (_, index) => index) diff --git a/benchmarks/client-nav/vue/tsconfig.json b/benchmarks/client-nav/vue/tsconfig.json index d8c92cfc17..afa1ac4e46 100644 --- a/benchmarks/client-nav/vue/tsconfig.json +++ b/benchmarks/client-nav/vue/tsconfig.json @@ -7,6 +7,8 @@ "types": ["node", "vite/client", "vitest/globals"] }, "include": [ + "src/**/*.ts", + "src/**/*.tsx", "speed.bench.ts", "speed.flame.ts", "../jsdom.ts", diff --git a/benchmarks/client-nav/vue/vite.config.ts b/benchmarks/client-nav/vue/vite.config.ts index d917c82e2d..87e4a709fc 100644 --- a/benchmarks/client-nav/vue/vite.config.ts +++ b/benchmarks/client-nav/vue/vite.config.ts @@ -1,8 +1,11 @@ +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' import codspeedPlugin from '@codspeed/vitest-plugin' +const setupFile = fileURLToPath(new URL('../vitest.setup.ts', import.meta.url)) + export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify('production'), @@ -18,7 +21,7 @@ export default defineConfig({ emptyOutDir: true, minify: false, lib: { - entry: './vue/app.tsx', + entry: './vue/src/app.tsx', formats: ['es'], fileName: 'app', }, @@ -27,6 +30,6 @@ export default defineConfig({ name: '@benchmarks/client-nav (vue)', watch: false, environment: 'jsdom', - setupFiles: ['./vitest.setup.ts'], + setupFiles: [setupFile], }, })