diff --git a/RESULT-perf-task8.md b/RESULT-perf-task8.md new file mode 100644 index 0000000000..63689f9962 --- /dev/null +++ b/RESULT-perf-task8.md @@ -0,0 +1,56 @@ +# RESULT — perf/task8-wildcard-suffix-offsets + +## Verdict: IMPLEMENTED (both gates pass) + +Offset-based wildcard suffix comparison in `getNodeMatch` +(`packages/router-core/src/new-process-route-tree.ts`) replacing +`parts.slice(index).join('/').slice(-suffix.length)` with a direct comparison +against the tail of `path`, using a per-candidate integer offset loop. + +## Bundle-size gate (react-router.minimal) + +| Build | gzip | delta vs baseline | +| ---------------- | ------- | ----------------- | +| baseline (HEAD) | 85864 | — | +| change (v1, helper + offsets array) | 85921 | **+57** (fails ±20) | +| change (v2, final: inline int loop) | 85882 | **+18** (passes ±20) | + +Brotli: 74780 → 74746 (−34). Raw: 269091 → 269147 (+56). + +## Worst-case benchmark (`tests/wildcard-suffix.perf.test.ts`, gated by RUN_BACKPRESSURE_PERF=1) + +Tree with one trie node holding 8 suffixed-wildcard candidates (all evaluated +per frame); URLs ~200 chars / ~46 segments; `matchCache.clear()` between +iterations to measure matching itself. + +| Workload | before (old) | after (new) | speedup | +| ------------------------------- | ------------ | ----------- | ------- | +| worst-case miss (~200ch URL) | 0.46us/match | 0.17us/match | **2.7x** | +| worst-case hit (~200ch URL) | 0.48us/match | 0.21us/match | **2.3x** | +| realistic mix (~40ch URL) | 0.46us/match | 0.31us/match | 1.5x | + +Gate was >30%; achieved ~63% time reduction on the worst case. + +Note: an earlier bench iteration showed no difference because +`findRouteMatch` memoizes per path via `matchCache`; numbers above bypass it. +The quadratic cost also requires many *segments* after the split point (not +just many characters in one segment). + +## Correctness + +- `tests/wildcard-suffix-differential.test.ts`: old implementation vendored as + `tests/wildcard-suffix-fixture.old.ts`; 20,000+ generated tree/path/fuzzy + comparisons (seeded PRNG) plus explicit edge cases (case-insensitivity, + suffix containing '/', remainder shorter than suffix, trailing slash, + empty suffix) — identical route ids and rawParams throughout. +- Full suite: router-core `test:unit` 107 files / 1608 tests passed, + `test:eslint` and `test:types` passed. + +## Semantics equivalence + +`path.split('/')` then `parts.slice(index).join('/')` exactly reconstructs the +substring of `path` starting at `index + sum(len(parts[0..index-1]))`. The old +`.slice(-suffix.length)` yields the whole remainder when it is shorter than +the suffix (length mismatch ⇒ never equals); the new `endPos < start` check is +equivalent. Case-insensitive path lowercases only the extracted tail in both +versions. Suffixes containing '/' operate on the raw path in both versions. diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 0baf693042..0c67912d80 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1061,7 +1061,13 @@ function getNodeMatch( } if (suffix) { if (isBeyondPath) continue - const end = parts.slice(index).join('/').slice(-suffix.length) + // the tail of the remaining URL always extends to the end of `path`, + // so compare against `path` directly instead of slice/join + let start = index + for (let j = 0; j < index; j++) start += parts[j]!.length + const endPos = path.length - suffix.length + if (endPos < start) continue + const end = path.slice(endPos) const casePart = segment.caseSensitive ? end : end.toLowerCase() if (casePart !== suffix) continue } diff --git a/packages/router-core/tests/wildcard-suffix-differential.test.ts b/packages/router-core/tests/wildcard-suffix-differential.test.ts new file mode 100644 index 0000000000..8e12e27ab8 --- /dev/null +++ b/packages/router-core/tests/wildcard-suffix-differential.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' +import { + findRouteMatch as findRouteMatchOld, + processRouteTree as processRouteTreeOld, +} from './wildcard-suffix-fixture.old' + +// seeded PRNG (mulberry32) for reproducibility +function makeRng(seed: number) { + return () => { + seed |= 0 + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const SEGMENTS = ['a', 'bb', 'ccc', 'file', 'data', 'x'] + +const WILDCARD_ROUTES = [ + '/{$}.txt', + '/files/{$}', + '/files/{$}.json', + '/files/{$}.tar.gz', + '/files/pre{$}', + '/files/pre{$}.json', + '/pre{$}/data.json', + '/a/{$}', + '/a/{$}.md', + '/a/b/{$}.txt', + '/Case{$}.TXT', + '/casesensitive/{$.suffix}', + '/with/slash/in/{$}a/b', + '/deep/nest/ed/{$}.log', +] + +const STATIC_ROUTES = [ + '/', + '/files', + '/files/data.txt', + '/a', + '/a/b', + '/a/b/c', + '/pre/data.json', + '/CaseFile.TXT', +] + +function pick(rng: () => number, arr: Array): T { + return arr[Math.floor(rng() * arr.length)]! +} + +function randomPath(rng: () => number): string { + const depth = Math.floor(rng() * 5) + const segments: Array = [] + for (let i = 0; i < depth; i++) { + const seg = pick(rng, SEGMENTS) + // randomly vary case or append extensions to exercise suffix matching + const roll = rng() + if (roll < 0.3) segments.push(seg.toUpperCase()) + else if (roll < 0.6) + segments.push(seg + pick(rng, ['.txt', '.json', '.md', '.tar.gz', ''])) + else segments.push(seg) + } + let path = '/' + segments.join('/') + if (rng() < 0.15) path += '/' // trailing slash + if (path === '//') path = '/' + return path +} + +/** + * Differential test for the offset-based wildcard suffix comparison. + * + * The suffix check used to allocate `parts.slice(index).join('/')` per + * candidate; it now compares directly against the tail of `path` using a + * character offset. This test pins that both implementations agree on the + * matched route id and raw params across many generated trees/paths, + * including case-insensitive suffixes, suffixes containing '/', empty + * remainders shorter than the suffix, and trailing slashes. + */ +describe('wildcard suffix matching (offset-based)', () => { + it('matches identically to parts.slice(index).join("/") semantics across generated trees and paths', () => { + const rng = makeRng(1337) + let checked = 0 + let matched = 0 + for (let iter = 0; iter < 200; iter++) { + const routes = [ + ...STATIC_ROUTES, + ...WILDCARD_ROUTES.filter(() => rng() < 0.7), + ] + const routeLike = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: routes.map((route) => ({ + id: route, + fullPath: route, + path: route.replace(/^\/|\/$/g, '') || '/', + })), + } + const treeNew = processRouteTree(routeLike).processedTree + const treeOld = processRouteTreeOld(routeLike).processedTree + + for (let p = 0; p < 50; p++) { + const path = randomPath(rng) + for (const fuzzy of [false, true]) { + const result = findRouteMatch(path, treeNew, fuzzy) + const expected = findRouteMatchOld(path, treeOld, fuzzy) + checked++ + expect( + { id: result?.route.id, params: result?.rawParams }, + `mismatch for path="${path}" fuzzy=${fuzzy} routes=[${routes.join(',')}]`, + ).toEqual({ id: expected?.route.id, params: expected?.rawParams }) + if (result) matched++ + } + } + } + expect(checked).toBeGreaterThan(15000) + // sanity: the workload actually exercised matches, not just misses + expect(matched).toBeGreaterThan(1000) + }) + + it('handles edge cases: case-insensitivity, "/" in suffix, remainder shorter than suffix', () => { + const routes = [ + '/case{$}.txt', + '/CASE{$}.TXT', + '/multi{$}a/b', + '/long{$}.tar.gz', + ] + const routeLike = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: routes.map((route) => ({ + id: route, + fullPath: route, + path: route.replace(/^\/|\/$/g, '') || '/', + })), + } + const treeNew = processRouteTree(routeLike).processedTree + const treeOld = processRouteTreeOld(routeLike).processedTree + + const paths = [ + '/casexyz.txt', // hit + '/caseXYZ.TXT', // suffix mismatch when case-insensitive (default) + '/CASEabc.TXT', // hit (segment stored uppercase + caseSensitive?) + '/multifoo/a/b', // suffix containing '/' + '/multibara/b', // prefix+suffix with '/' + '/lon.tar.gz', // remainder before wildcard shorter than needed + '/lo.g', // way too short + '/longfile.name.tar.gz', // hit with dot-containing splat + '/case/', // trailing slash, empty remainder + ] + for (const path of paths) { + for (const fuzzy of [false, true]) { + expect( + { + id: findRouteMatch(path, treeNew, fuzzy)?.route.id, + params: findRouteMatch(path, treeNew, fuzzy)?.rawParams, + }, + `path="${path}" fuzzy=${fuzzy}`, + ).toEqual({ + id: findRouteMatchOld(path, treeOld, fuzzy)?.route.id, + params: findRouteMatchOld(path, treeOld, fuzzy)?.rawParams, + }) + } + } + }) +}) diff --git a/packages/router-core/tests/wildcard-suffix-fixture.old.ts b/packages/router-core/tests/wildcard-suffix-fixture.old.ts new file mode 100644 index 0000000000..081c56092f --- /dev/null +++ b/packages/router-core/tests/wildcard-suffix-fixture.old.ts @@ -0,0 +1,1282 @@ +import { invariant } from '../src/invariant' +import { createLRUCache } from '../src/lru-cache' +import { last } from '../src/utils' +import type { LRUCache } from '../src/lru-cache' + +export const SEGMENT_TYPE_PATHNAME = 0 +export const SEGMENT_TYPE_PARAM = 1 +export const SEGMENT_TYPE_WILDCARD = 2 +export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 +const SEGMENT_TYPE_INDEX = 4 +const SEGMENT_TYPE_PATHLESS = 5 // only used in matching to represent pathless routes that need to carry more information + +/** + * All the kinds of segments that can be present in a route path. + */ +export type SegmentKind = + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM + +/** + * All the kinds of segments that can be present in the segment tree. + */ +type ExtendedSegmentKind = + | SegmentKind + | typeof SEGMENT_TYPE_INDEX + | typeof SEGMENT_TYPE_PATHLESS + +type ParsedSegment = Uint16Array & { + /** segment type (0 = pathname, 1 = param, 2 = wildcard, 3 = optional param) */ + 0: SegmentKind + /** index of the end of the prefix */ + 1: number + /** index of the start of the value */ + 2: number + /** index of the end of the value */ + 3: number + /** index of the start of the suffix */ + 4: number + /** index of the end of the segment */ + 5: number +} + +/** + * Populates the `output` array with the parsed representation of the given `segment` string. + * + * Usage: + * ```ts + * let output + * let cursor = 0 + * while (cursor < path.length) { + * output = parseSegment(path, cursor, output) + * const end = output[5] + * cursor = end + 1 + * ``` + * + * `output` is stored outside to avoid allocations during repeated calls. It doesn't need to be typed + * or initialized, it will be done automatically. + */ +export function parseSegment( + /** The full path string containing the segment. */ + path: string, + /** The starting index of the segment within the path. */ + start: number, + /** A Uint16Array (length: 6) to populate with the parsed segment data. */ + output: Uint16Array = new Uint16Array(6), +): ParsedSegment { + const next = path.indexOf('/', start) + const end = next === -1 ? path.length : next + const part = path.substring(start, end) + + if (!part || !part.includes('$')) { + // early escape for static pathname + output[0] = SEGMENT_TYPE_PATHNAME + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return output as ParsedSegment + } + + // $ (wildcard) + if (part === '$') { + const total = path.length + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + output[2] = start + output[3] = total + output[4] = total + output[5] = total + return output as ParsedSegment + } + + // $paramName + if (part.charCodeAt(0) === 36) { + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + output[2] = start + 1 // skip '$' + output[3] = end + output[4] = end + output[5] = end + return output as ParsedSegment + } + + const openBrace = part.indexOf('{') + let closeBrace + if ( + openBrace !== -1 && + openBrace + 1 < part.length && + (closeBrace = part.indexOf('}', openBrace)) !== -1 + ) { + const firstChar = part.charCodeAt(openBrace + 1) + + // Check for {-$...} (optional param) + // prefix{-$paramName}suffix + // /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ + if (firstChar === 45) { + // '-' + if ( + openBrace + 2 < part.length && + part.charCodeAt(openBrace + 2) === 36 // '$' + ) { + const paramStart = openBrace + 3 + const paramEnd = closeBrace + // Validate param name exists + if (paramStart < paramEnd) { + output[0] = SEGMENT_TYPE_OPTIONAL_PARAM + output[1] = start + openBrace + output[2] = start + paramStart + output[3] = start + paramEnd + output[4] = start + closeBrace + 1 + output[5] = end + return output as ParsedSegment + } + } + } else if (firstChar === 36) { + // '$' + const dollarPos = openBrace + 1 + const afterDollar = openBrace + 2 + // Check for {$} (wildcard) + if (afterDollar === closeBrace) { + // For wildcard, value should be '$' (from dollarPos to afterDollar) + // prefix{$}suffix + // /^([^{]*)\{\$\}([^}]*)$/ + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + openBrace + output[2] = start + dollarPos + output[3] = start + afterDollar + output[4] = start + closeBrace + 1 + output[5] = path.length + return output as ParsedSegment + } + // Regular param {$paramName} - value is the param name (after $) + // prefix{$paramName}suffix + // /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + openBrace + output[2] = start + afterDollar + output[3] = start + closeBrace + output[4] = start + closeBrace + 1 + output[5] = end + return output as ParsedSegment + } + } + + // fallback to static pathname (should never happen) + output[0] = SEGMENT_TYPE_PATHNAME + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return output as ParsedSegment +} + +/** + * Recursively parses the segments of the given route tree and populates a segment trie. + * + * @param data A reusable Uint16Array for parsing segments. (non important, we're just avoiding allocations) + * @param route The current route to parse. + * @param start The starting index for parsing within the route's full path. + * @param node The current segment node in the trie to populate. + * @param onRoute Callback invoked for each route processed. + */ +function parseSegments( + defaultCaseSensitive: boolean, + data: Uint16Array, + route: TRouteLike, + start: number, + node: AnySegmentNode, + depth: number, + /** Each dynamic sibling list is recorded once, when it first needs sorting. */ + dynamicListsToSort?: Array>>, + onRoute?: (route: TRouteLike) => void, +) { + onRoute?.(route) + let cursor = start + { + const path = route.fullPath ?? route.from + const options = route.options + const length = path.length + const caseSensitive = options?.caseSensitive ?? defaultCaseSensitive + const parseParams = options?.params?.parse ?? options?.parseParams + while (cursor < length) { + const segment = parseSegment(path, cursor, data) + let nextNode: AnySegmentNode + const start = cursor + const end = segment[5] + cursor = end + 1 + depth++ + const kind = segment[0] + switch (kind) { + case SEGMENT_TYPE_PATHNAME: { + const value = path.substring(segment[2], segment[3]) + let name = value + let staticChildren: Map> + if (caseSensitive) { + staticChildren = node.static ??= new Map() + } else { + name = value.toLowerCase() + staticChildren = node.staticInsensitive ??= new Map() + } + const existingNode = staticChildren.get(name) + if (existingNode) { + nextNode = existingNode + } else { + const next = createStaticNode(path) + next.parent = node + next.depth = depth + nextNode = next + staticChildren.set(name, next) + } + break + } + case SEGMENT_TYPE_PARAM: + case SEGMENT_TYPE_OPTIONAL_PARAM: + case SEGMENT_TYPE_WILDCARD: { + const prefix_raw = path.substring(start, segment[1]) + const suffix_raw = path.substring(segment[4], end) + const actuallyCaseSensitive = + caseSensitive && !!(prefix_raw || suffix_raw) + const prefix = !prefix_raw + ? undefined + : actuallyCaseSensitive + ? prefix_raw + : prefix_raw.toLowerCase() + const suffix = !suffix_raw + ? undefined + : actuallyCaseSensitive + ? suffix_raw + : suffix_raw.toLowerCase() + const siblings = + kind === SEGMENT_TYPE_PARAM + ? node.dynamic + : kind === SEGMENT_TYPE_OPTIONAL_PARAM + ? node.optional + : node.wildcard + const existingNode = + // Keep wildcard aliases as separate match candidates, even when + // they have the same shape and no parser. + kind !== SEGMENT_TYPE_WILDCARD && + !parseParams && + siblings?.find( + (s) => + !s.parse && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) + if (existingNode) { + nextNode = existingNode + } else { + const next = createDynamicNode( + kind, + path, + actuallyCaseSensitive, + prefix, + suffix, + ) + nextNode = next + next.parent = node + next.depth = depth + let nodes: Array> + if (kind === SEGMENT_TYPE_PARAM) { + nodes = node.dynamic ??= [] + } else if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + nodes = node.optional ??= [] + } else { + nodes = node.wildcard ??= [] + } + nodes.push(next) + if (nodes.length === 2) { + dynamicListsToSort?.push(nodes) + } + } + break + } + } + node = nextNode + } + + // create pathless node + if ( + parseParams && + route.children && + !route.isRoot && + route.id && + route.id.charCodeAt(route.id.lastIndexOf('/') + 1) === 95 /* '_' */ + ) { + const pathlessNode = createStaticNode(path) + pathlessNode.kind = SEGMENT_TYPE_PATHLESS + pathlessNode.parent = node + depth++ + pathlessNode.depth = depth + node.pathless ??= [] + node.pathless.push(pathlessNode) + node = pathlessNode + } + + const isLeaf = (route.path || !route.children) && !route.isRoot + // create index node + if (isLeaf && path.endsWith('/')) { + const indexNode = createStaticNode(path) + indexNode.kind = SEGMENT_TYPE_INDEX + indexNode.parent = node + depth++ + indexNode.depth = depth + node.index = indexNode + node = indexNode + } + + node.parse = parseParams ?? null + node.priority = options?.params?.priority ?? 0 + + // make node "matchable" + if (isLeaf && !node.route) { + node.route = route + node.fullPath = path + } + } + if (route.children) + for (const child of route.children) { + parseSegments( + defaultCaseSensitive, + data, + child as TRouteLike, + cursor, + node, + depth, + dynamicListsToSort, + onRoute, + ) + } +} + +function sortDynamic( + a: { + prefix?: string + suffix?: string + caseSensitive: boolean + parse: null | ((params: Record) => unknown) + priority: number + }, + b: { + prefix?: string + suffix?: string + caseSensitive: boolean + parse: null | ((params: Record) => unknown) + priority: number + }, +) { + if (a.parse && !b.parse) return -1 + if (!a.parse && b.parse) return 1 + if (a.parse && b.parse && (a.priority || b.priority)) + return b.priority - a.priority + if (a.prefix && b.prefix && a.prefix !== b.prefix) { + if (a.prefix.startsWith(b.prefix)) return -1 + if (b.prefix.startsWith(a.prefix)) return 1 + } + if (a.suffix && b.suffix && a.suffix !== b.suffix) { + if (a.suffix.endsWith(b.suffix)) return -1 + if (b.suffix.endsWith(a.suffix)) return 1 + } + if (a.prefix && !b.prefix) return -1 + if (!a.prefix && b.prefix) return 1 + if (a.suffix && !b.suffix) return -1 + if (!a.suffix && b.suffix) return 1 + if (a.caseSensitive && !b.caseSensitive) return -1 + if (!a.caseSensitive && b.caseSensitive) return 1 + + // Equal specificity preserves route declaration order through stable sort. + return 0 +} + +function createStaticNode( + fullPath: string, +): StaticSegmentNode { + return { + kind: SEGMENT_TYPE_PATHNAME, + depth: 0, + pathless: null, + index: null, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + route: null, + fullPath, + parent: null, + parse: null, + priority: 0, + } +} + +/** + * Keys must be declared in the same order as in `SegmentNode` type, + * to ensure they are represented as the same object class in the engine. + */ +function createDynamicNode( + kind: + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM, + fullPath: string, + caseSensitive: boolean, + prefix?: string, + suffix?: string, +): DynamicSegmentNode { + return { + kind, + depth: 0, + pathless: null, + index: null, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + route: null, + fullPath, + parent: null, + parse: null, + priority: 0, + caseSensitive, + prefix, + suffix, + } +} + +type StaticSegmentNode = SegmentNode & { + kind: + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX +} + +type DynamicSegmentNode = SegmentNode & { + kind: + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM + prefix?: string + suffix?: string + caseSensitive: boolean +} + +type AnySegmentNode = + | StaticSegmentNode + | DynamicSegmentNode + +type SegmentNode = { + kind: ExtendedSegmentKind + + pathless: Array> | null + + /** Exact index segment (highest priority) */ + index: StaticSegmentNode | null + + /** Static segments (2nd priority) */ + static: Map> | null + + /** Case insensitive static segments (3rd highest priority) */ + staticInsensitive: Map> | null + + /** Dynamic segments ($param) */ + dynamic: Array> | null + + /** Optional dynamic segments ({-$param}) */ + optional: Array> | null + + /** Wildcard segments ($ - lowest priority) */ + wildcard: Array> | null + + /** Terminal route (if this path can end here) */ + route: T | null + + /** The full path for this segment node (will only be valid on leaf nodes) */ + fullPath: string + + parent: AnySegmentNode | null + + depth: number + + /** route.options.params.parse function, set on the last node of the route */ + parse: null | ((params: Record) => unknown) + + /** route.options.params.priority ?? 0 */ + priority: number +} + +type RouteLike = { + id?: string + path?: string // relative path from the parent, + children?: Array // child routes, + parentRoute?: RouteLike // parent route, + isRoot?: boolean + options?: { + caseSensitive?: boolean + parseParams?: (params: Record) => unknown + params?: { + parse?: (params: Record) => unknown + priority?: number + } + } +} & + // router tree + (| { fullPath: string; from?: never } // full path from the root + // flat route masks list + | { fullPath?: never; from: string } // full path from the root + ) + +export type ProcessedTree< + TTree extends Extract, + TFlat extends Extract, + TSingle extends Extract, +> = { + /** a representation of the `routeTree` as a segment tree */ + segmentTree: AnySegmentNode + /** a mini route tree generated from the flat `routeMasks` list */ + masksTree: AnySegmentNode | null + /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ + singleCache: LRUCache> + /** a cache of route matches from the `segmentTree` */ + matchCache: LRUCache | null> + /** a cache of route matches from the `masksTree` */ + flatCache: LRUCache>> | null +} + +export function processRouteMasks< + TRouteLike extends Extract, +>( + routeList: Array, + processedTree: ProcessedTree, +) { + const segmentTree = createStaticNode('/') + const data = new Uint16Array(6) + const dynamicListsToSort: Array>> = [] + for (const route of routeList) { + parseSegments(false, data, route, 1, segmentTree, 0, dynamicListsToSort) + } + for (const nodes of dynamicListsToSort) { + nodes.sort(sortDynamic) + } + processedTree.masksTree = segmentTree + processedTree.flatCache = createLRUCache< + string, + ReturnType> + >(1000) +} + +/** + * Take an arbitrary list of routes, create a tree from them (if it hasn't been created already), and match a path against it. + */ +export function findFlatMatch>( + /** The path to match. */ + path: string, + /** The `processedTree` returned by the initial `processRouteTree` call. */ + processedTree: ProcessedTree, +) { + path ||= '/' + const cached = processedTree.flatCache!.get(path) + if (cached !== undefined) return cached + const result = findMatch(path, processedTree.masksTree!) + processedTree.flatCache!.set(path, result) + return result +} + +/** + * @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree + */ +export function findSingleMatch( + from: string, + caseSensitive: boolean, + fuzzy: boolean, + path: string, + processedTree: ProcessedTree, +) { + from ||= '/' + path ||= '/' + const key = caseSensitive ? `case\0${from}` : from + let tree = processedTree.singleCache.get(key) + if (!tree) { + // single flat routes (router.matchRoute) are not eagerly processed, + // if we haven't seen this route before, process it now + tree = createStaticNode<{ from: string }>('/') + const data = new Uint16Array(6) + parseSegments(caseSensitive, data, { from }, 1, tree, 0) + processedTree.singleCache.set(key, tree) + } + return findMatch(path, tree, fuzzy) +} + +type RouteMatch> = { + route: T + rawParams: Record + branch: ReadonlyArray +} + +export function findRouteMatch< + T extends Extract, +>( + /** The path to match against the route tree. */ + path: string, + /** The `processedTree` returned by the initial `processRouteTree` call. */ + processedTree: ProcessedTree, + /** If `true`, allows fuzzy matching (partial matches), i.e. which node in the tree would have been an exact match if the `path` had been shorter? */ + fuzzy = false, +): RouteMatch | null { + const key = fuzzy ? path : `nofuzz\0${path}` // the main use for `findRouteMatch` is fuzzy:true, so we optimize for that case + const cached = processedTree.matchCache.get(key) + if (cached !== undefined) return cached + path ||= '/' + let result: RouteMatch | null + + try { + result = findMatch( + path, + processedTree.segmentTree, + fuzzy, + ) as RouteMatch | null + } catch (err) { + if (err instanceof URIError) { + result = null + } else { + throw err + } + } + + if (result) result.branch = buildRouteBranch(result.route) + processedTree.matchCache.set(key, result) + return result +} + +/** Trim trailing slashes (except preserving root '/'). */ +export function trimPathRight(path: string) { + return path === '/' ? path : path.replace(/\/{1,}$/, '') +} + +export interface ProcessRouteTreeResult< + TRouteLike extends Extract & { id: string }, +> { + /** Should be considered a black box, needs to be provided to all matching functions in this module. */ + processedTree: ProcessedTree + /** A lookup map of routes by their unique IDs. */ + routesById: Record + /** A lookup map of routes by their trimmed full paths. */ + routesByPath: Record +} + +/** + * Processes a route tree into a segment trie for efficient path matching. + * Also builds lookup maps for routes by ID and by trimmed full path. + */ +export function processRouteTree< + TRouteLike extends Extract & { id: string }, +>( + /** The root of the route tree to process. */ + routeTree: TRouteLike, + /** Whether matching should be case sensitive by default (overridden by individual route options). */ + caseSensitive: boolean = false, + /** Optional callback invoked for each route during processing. */ + initRoute?: (route: TRouteLike, index: number) => void, +): ProcessRouteTreeResult { + const segmentTree = createStaticNode(routeTree.fullPath) + const data = new Uint16Array(6) + const dynamicListsToSort: Array>> = [] + const routesById = {} as Record + const routesByPath = {} as Record + let index = 0 + parseSegments( + caseSensitive, + data, + routeTree, + 1, + segmentTree, + 0, + dynamicListsToSort, + (route) => { + initRoute?.(route, index) + + if (route.id in routesById) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `Invariant failed: Duplicate routes found with id: ${String(route.id)}`, + ) + } + + invariant() + } + + routesById[route.id] = route + + if (index !== 0 && route.path) { + const trimmedFullPath = trimPathRight(route.fullPath) + if (!routesByPath[trimmedFullPath] || route.fullPath.endsWith('/')) { + routesByPath[trimmedFullPath] = route + } + } + + index++ + }, + ) + for (const nodes of dynamicListsToSort) { + nodes.sort(sortDynamic) + } + const processedTree: ProcessedTree = { + segmentTree, + singleCache: createLRUCache>(1000), + matchCache: createLRUCache | null>(1000), + flatCache: null, + masksTree: null, + } + return { + processedTree, + routesById, + routesByPath, + } +} + +function findMatch( + path: string, + segmentTree: AnySegmentNode, + fuzzy = false, +): { + route: T + /** + * The raw (unparsed) params extracted from the path. + * This will be the exhaustive list of all params defined in the route's path. + */ + rawParams: Record +} | null { + const parts = path.split('/') + const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) + if (!leaf) return null + const [rawParams] = extractParams(path, parts, leaf) + return { + route: leaf.node.route!, + rawParams, + } +} + +type ParamExtractionState = { + part: number + node: number + path: number + segment: number +} + +/** + * This function is "resumable": + * - the `leaf` input can contain `extract` and `rawParams` properties from a previous `extractParams` call + * - the returned `state` can be passed back as `extract` in a future call to continue extracting params from where we left off + * + * Inputs are *not* mutated. + */ +function extractParams( + path: string, + parts: Array, + leaf: { + node: AnySegmentNode + skipped: number + extract?: ParamExtractionState + rawParams?: Record + }, +): [rawParams: Record, state: ParamExtractionState] { + const list = buildBranch(leaf.node) + let nodeParts: Array | null = null + const rawParams: Record = Object.create(null) + /** which segment of the path we're currently processing */ + let partIndex = leaf.extract?.part ?? 0 + /** which node of the route tree branch we're currently processing */ + let nodeIndex = leaf.extract?.node ?? 0 + /** index of the 1st character of the segment we're processing in the path string */ + let pathIndex = leaf.extract?.path ?? 0 + /** which fullPath segment we're currently processing */ + let segmentCount = leaf.extract?.segment ?? 0 + for ( + ; + nodeIndex < list.length; + partIndex++, nodeIndex++, pathIndex++, segmentCount++ + ) { + const node = list[nodeIndex]! + // index nodes are terminating nodes, nothing to extract, just leave + if (node.kind === SEGMENT_TYPE_INDEX) break + // pathless nodes do not consume a path segment + if (node.kind === SEGMENT_TYPE_PATHLESS) { + segmentCount-- + partIndex-- + pathIndex-- + continue + } + const part = parts[partIndex] + const currentPathIndex = pathIndex + if (part) pathIndex += part.length + if (node.kind === SEGMENT_TYPE_PARAM) { + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[segmentCount]! + const preLength = node.prefix?.length ?? 0 + // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix + const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' + // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node + if (isCurlyBraced) { + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 2, + nodePart.length - sufLength - 1, + ) + const value = part!.substring(preLength, part!.length - sufLength) + rawParams[name] = decodeURIComponent(value) + } else { + const name = nodePart.substring(1) + rawParams[name] = decodeURIComponent(part!) + } + } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + if (leaf.skipped & (1 << nodeIndex)) { + partIndex-- // stay on the same part + pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment + continue + } + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[segmentCount]! + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 3, + nodePart.length - sufLength - 1, + ) + const value = + node.suffix || node.prefix + ? part!.substring(preLength, part!.length - sufLength) + : part + if (value) rawParams[name] = decodeURIComponent(value) + } else if (node.kind === SEGMENT_TYPE_WILDCARD) { + const n = node + const value = path.substring( + currentPathIndex + (n.prefix?.length ?? 0), + path.length - (n.suffix?.length ?? 0), + ) + const splat = decodeURIComponent(value) + // TODO: Deprecate * + rawParams['*'] = splat + rawParams._splat = splat + break + } + } + if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) + return [ + rawParams, + { + part: partIndex, + node: nodeIndex, + path: pathIndex, + segment: segmentCount, + }, + ] +} + +export function buildRouteBranch(route: T) { + const list = [route] + while (route.parentRoute) { + route = route.parentRoute as T + list.push(route) + } + list.reverse() + return list +} + +function buildBranch(node: AnySegmentNode) { + const list: Array> = Array(node.depth + 1) + do { + list[node.depth] = node + node = node.parent! + } while (node) + return list +} + +type MatchStackFrame = { + node: AnySegmentNode + /** index of the segment of path */ + index: number + /** + * Bitmask of skipped optional segments. + * + * This is a very performant way of storing an "array of booleans", but it means beyond 32 segments we can't track skipped optionals. + * If we really really need to support more than 32 segments we can switch to using a `BigInt` here. It's about 2x slower in worst case scenarios. + */ + skipped: number + /** Positional bitmasks tracking which consumed URL segments matched each segment kind. */ + statics: number + dynamics: number + optionals: number + /** intermediary state for param extraction */ + extract?: ParamExtractionState + /** intermediary params from param extraction */ + rawParams?: Record +} + +function getNodeMatch( + path: string, + parts: Array, + segmentTree: AnySegmentNode, + fuzzy: boolean, +) { + // quick check for root index + // this is an optimization, algorithm should work correctly without this block + if (path === '/' && segmentTree.index) + return { node: segmentTree.index, skipped: 0 } as Pick< + Frame, + 'node' | 'skipped' + > + + const trailingSlash = !last(parts) + const pathIsIndex = trailingSlash && path !== '/' + const partsLength = parts.length - (trailingSlash ? 1 : 0) + + type Frame = MatchStackFrame + + // use a stack to explore all possible paths (params cause branching) + // iterate "backwards" (low priority first) so that we can push() each candidate, and pop() the highest priority candidate first + // - pros: it is depth-first, so we find full matches faster + // - cons: we cannot short-circuit, because highest priority matches are at the end of the loop (for loop with i--) (but we have no good short-circuiting anyway) + // other possible approaches: + // - shift instead of pop (measure performance difference), this allows iterating "forwards" (effectively breadth-first) + // - never remove from the stack, keep a cursor instead. Then we can push "forwards" and avoid reversing the order of candidates (effectively breadth-first) + const stack: Array = [ + { + node: segmentTree, + index: 1, + skipped: 0, + statics: 0, + dynamics: 0, + optionals: 0, + }, + ] + + let bestFuzzy: Frame | null = null + let bestMatch: Frame | null = null + + while (stack.length) { + const frame = stack.pop()! + const { node, index, skipped, statics, dynamics, optionals } = frame + let { extract, rawParams } = frame + + // Wildcard candidates are pushed speculatively as fallbacks in case a + // higher-priority wildcard later fails params.parse. If a better wildcard + // has already validated and become bestMatch, lower-priority wildcard + // fallbacks cannot win anymore and should not run params.parse. + if ( + node.kind === SEGMENT_TYPE_WILDCARD && + node.route && + !isFrameMoreSpecific(bestMatch, frame) + ) { + continue + } + + if (node.parse) { + const result = validateParseParams(path, parts, frame) + if (!result) continue + rawParams = frame.rawParams + extract = frame.extract + } + + // In fuzzy mode, track the best partial match we've found so far + if ( + fuzzy && + node.route && + node.kind !== SEGMENT_TYPE_INDEX && + isFrameMoreSpecific(bestFuzzy, frame) + ) { + bestFuzzy = frame + } + + const isBeyondPath = index === partsLength + if (isBeyondPath) { + if ( + node.route && + (!pathIsIndex || + node.kind === SEGMENT_TYPE_INDEX || + node.kind === SEGMENT_TYPE_WILDCARD) && + isFrameMoreSpecific(bestMatch, frame) + ) { + bestMatch = frame + } + // beyond the length of the path parts, only some segment types can match + if (!node.optional && !node.wildcard && !node.index && !node.pathless) + continue + } + + const part = isBeyondPath ? undefined : parts[index]! + let lowerPart: string + + // 0. Try index match + if (isBeyondPath && node.index) { + const indexFrame = { + node: node.index, + index, + skipped, + statics, + dynamics, + optionals, + extract, + rawParams, + } + let indexValid = true + if (node.index.parse) { + const result = validateParseParams(path, parts, indexFrame) + if (!result) indexValid = false + } + if (indexValid) { + // perfect match, no need to continue + // this is an optimization, algorithm should work correctly without this block + if ( + !dynamics && + !optionals && + !skipped && + isPerfectStaticMatch(statics, partsLength) + ) { + return indexFrame + } + if (isFrameMoreSpecific(bestMatch, indexFrame)) { + // index matches skip the stack because they cannot have children + bestMatch = indexFrame + } + } + } + + // 5. Try wildcard match + if (node.wildcard) { + for (let i = node.wildcard.length - 1; i >= 0; i--) { + const segment = node.wildcard[i]! + const { prefix, suffix } = segment + if (prefix) { + if (isBeyondPath) continue + const casePart = segment.caseSensitive + ? part + : (lowerPart ??= part!.toLowerCase()) + if (!casePart!.startsWith(prefix)) continue + } + if (suffix) { + if (isBeyondPath) continue + const end = parts.slice(index).join('/').slice(-suffix.length) + const casePart = segment.caseSensitive ? end : end.toLowerCase() + if (casePart !== suffix) continue + } + // wildcard matches consume the rest of the URL and cannot have children + stack.push({ + node: segment, + index: partsLength, + skipped, + statics, + dynamics, + optionals, + extract, + rawParams, + }) + } + } + + // 4. Try optional match + if (node.optional) { + // A skipped optional is keyed by the child node's trie depth. + const nextSkipped = skipped | (1 << (node.depth + 1)) + for (let i = node.optional.length - 1; i >= 0; i--) { + const segment = node.optional[i]! + // when skipping, the node advances by 1, but the index doesn't + stack.push({ + node: segment, + index, + skipped: nextSkipped, + statics, + dynamics, + optionals, + extract, + rawParams, + }) // enqueue skipping the optional + } + if (!isBeyondPath) { + for (let i = node.optional.length - 1; i >= 0; i--) { + const segment = node.optional[i]! + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive + ? part! + : (lowerPart ??= part!.toLowerCase()) + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue + } + stack.push({ + node: segment, + index: index + 1, + skipped, + statics, + dynamics, + optionals: optionals + segmentScore(partsLength, index), + extract, + rawParams, + }) + } + } + } + + // 3. Try dynamic match + if (!isBeyondPath && node.dynamic && part) { + for (let i = node.dynamic.length - 1; i >= 0; i--) { + const segment = node.dynamic[i]! + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive + ? part + : (lowerPart ??= part.toLowerCase()) + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue + } + stack.push({ + node: segment, + index: index + 1, + skipped, + statics, + dynamics: dynamics + segmentScore(partsLength, index), + optionals, + extract, + rawParams, + }) + } + } + + // 2. Try case insensitive static match + if (!isBeyondPath && node.staticInsensitive) { + const match = node.staticInsensitive.get( + (lowerPart ??= part!.toLowerCase()), + ) + if (match) { + stack.push({ + node: match, + index: index + 1, + skipped, + statics: statics + segmentScore(partsLength, index), + dynamics, + optionals, + extract, + rawParams, + }) + } + } + + // 1. Try static match + if (!isBeyondPath && node.static) { + const match = node.static.get(part!) + if (match) { + stack.push({ + node: match, + index: index + 1, + skipped, + statics: statics + segmentScore(partsLength, index), + dynamics, + optionals, + extract, + rawParams, + }) + } + } + + // 0. Try pathless match + if (node.pathless) { + for (let i = node.pathless.length - 1; i >= 0; i--) { + const segment = node.pathless[i]! + stack.push({ + node: segment, + index, + skipped, + statics, + dynamics, + optionals, + extract, + rawParams, + }) + } + } + } + + if (bestMatch) return bestMatch + + if (fuzzy && bestFuzzy) { + let sliceIndex = bestFuzzy.index + for (let i = 0; i < bestFuzzy.index; i++) { + sliceIndex += parts[i]!.length + } + const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) + bestFuzzy.rawParams ??= Object.create(null) + bestFuzzy.rawParams!['**'] = decodeURIComponent(splat) + return bestFuzzy + } + + return null +} + +function segmentScore(partsLength: number, index: number): number { + // The specificity scores are bitmasks over consumed URL segments. Earlier + // URL segments should dominate later ones when comparing scores, so the + // first real segment gets the highest bit and the last gets bit 0. Since + // `parts[0]` is the empty string before the leading slash, real URL segments + // are [1, partsLength), making this segment's bit `partsLength - index - 1`. + return 2 ** (partsLength - index - 1) +} + +function isPerfectStaticMatch(statics: number, partsLength: number): boolean { + return statics === 2 ** (partsLength - 1) - 1 +} + +function validateParseParams( + path: string, + parts: Array, + frame: MatchStackFrame, +) { + let rawParams: Record + let state: ParamExtractionState + + try { + ;[rawParams, state] = extractParams(path, parts, frame) + } catch { + return null + } + + frame.rawParams = rawParams + frame.extract = state + + if (!frame.node.parse) return true + + try { + if (frame.node.parse(rawParams) === false) return null + } catch { + // Thrown parse errors should be surfaced on the selected match by + // extractStrictParams, not used as fallback route selection. + } + + return true +} + +function isFrameMoreSpecific( + // the stack frame previously saved as "best match" + prev: MatchStackFrame | null, + // the candidate stack frame + next: MatchStackFrame, +): boolean { + if (!prev) return true + return ( + next.statics > prev.statics || + (next.statics === prev.statics && + (next.dynamics > prev.dynamics || + (next.dynamics === prev.dynamics && + (next.optionals > prev.optionals || + (next.optionals === prev.optionals && + ((next.node.kind === SEGMENT_TYPE_INDEX) > + (prev.node.kind === SEGMENT_TYPE_INDEX) || + ((next.node.kind === SEGMENT_TYPE_INDEX) === + (prev.node.kind === SEGMENT_TYPE_INDEX) && + next.node.depth > prev.node.depth))))))) + ) +} diff --git a/packages/router-core/tests/wildcard-suffix.perf.test.ts b/packages/router-core/tests/wildcard-suffix.perf.test.ts new file mode 100644 index 0000000000..bcacd336be --- /dev/null +++ b/packages/router-core/tests/wildcard-suffix.perf.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' +import { + findRouteMatch as findRouteMatchOld, + processRouteTree as processRouteTreeOld, +} from './wildcard-suffix-fixture.old' + +/** + * Benchmark for the offset-based wildcard suffix comparison in + * `getNodeMatch` (new-process-route-tree.ts). + * + * The suffix check previously allocated `parts.slice(index).join('/')` per + * suffixed-wildcard candidate per stack frame, copying the remainder of the + * URL every time. The current implementation compares against the tail of + * `path` using a character offset instead. + * + * Run with: + * RUN_BACKPRESSURE_PERF=1 pnpm nx run @tanstack/router-core:test:unit -- tests/wildcard-suffix.perf.test.ts + */ +const SUFFIXES = [ + '.json', + '.txt', + '.xml', + '.md', + '.yaml', + '.html', + '.csv', + '.tsv', +] + +function makeTree(process: typeof processRouteTree) { + // ONE trie node (/files) holding 8 suffixed-wildcard candidates: every + // frame reaching this node evaluates all 8 suffix checks. + const routes = [ + '/', + '/files', + '/static/segment/path', + ...SUFFIXES.map((s) => `/files/{$}${s}`), + '/other', + ...SUFFIXES.slice(0, 4).map((s) => `/other/{$}${s}`), + ] + return process({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: routes.map((route) => ({ + id: route, + fullPath: route, + path: route.replace(/^\/|\/$/g, '') || '/', + })), + }).processedTree +} + +function body(seed: number): string { + let out = '' + for (let i = 0; i < 40; i++) { + out += String.fromCharCode(97 + ((seed * (i + 7) * 31) % 26)).repeat(4) + } + return '/' + out.replace(/(.{3})/g, '$1/') +} + +// ~200-char URLs (~46 segments): right prefix, wrong suffix. Every candidate's +// suffix check runs and the old code copied ~200 chars per check, 8x per call. +const missPaths = Array.from({ length: 500 }, (_, i) => `/files${body(i)}.zzz`) +// same shape, but the last-checked candidate matches +const hitPaths = missPaths.map((p) => p.slice(0, -4) + SUFFIXES[0]!) +// realistic short URLs hitting various candidates +const realPaths = Array.from( + { length: 500 }, + (_, i) => `/files${body(i).slice(0, 20)}${SUFFIXES[i % SUFFIXES.length]!}`, +) + +function bench( + fn: (p: string) => unknown, + tree: ReturnType, + paths: Array, + ms: number, +): number { + const end = performance.now() + ms + let ops = 0 + while (performance.now() < end) { + for (const p of paths) + fn(p) + // findRouteMatch memoizes per path; bypass so we measure matching itself + ;(tree as any).matchCache.clear() + ops += paths.length + } + return ops / (ms / 1000) +} + +describe('wildcard suffix comparison benchmark', () => { + const treeNew = makeTree(processRouteTree) + const treeOld = makeTree( + processRouteTreeOld as unknown as typeof processRouteTree, + ) + + it('old (slice/join) vs new (offset) on worst-case and realistic workloads', () => { + const scenarios = [ + ['worst-case miss (~200ch URL)', missPaths], + ['worst-case hit (~200ch URL)', hitPaths], + ['realistic mix (~40ch URL) ', realPaths], + ] as const + + for (const [label, paths] of scenarios) { + // sanity: identical match results + for (const p of paths.slice(0, 50)) { + expect(findRouteMatch(p, treeNew)?.route.id).toBe( + findRouteMatchOld(p, treeOld)?.route.id, + ) + } + const warm = (fn: (p: string) => unknown, t: any) => + bench(fn, t, paths, 300) + warm((p) => findRouteMatch(p, treeNew), treeNew) + warm((p) => findRouteMatchOld(p, treeOld), treeOld) + const newOps = bench( + (p) => findRouteMatch(p, treeNew), + treeNew, + paths, + 1000, + ) + const oldOps = bench( + (p) => findRouteMatchOld(p, treeOld), + treeOld, + paths, + 1000, + ) + console.log( + `${label}: old=${(1e6 / oldOps).toFixed(2)}us/match new=${(1e6 / newOps).toFixed(2)}us/match (${((oldOps / newOps - 1) * 100).toFixed(0)}% slower before)`, + ) + expect(newOps).toBeGreaterThan(0) + } + }, 30000) +})