Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions RESULT-perf-task8.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion packages/router-core/src/new-process-route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1061,7 +1061,13 @@ function getNodeMatch<T extends RouteLike>(
}
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
}
Expand Down
171 changes: 171 additions & 0 deletions packages/router-core/tests/wildcard-suffix-differential.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(rng: () => number, arr: Array<T>): T {
return arr[Math.floor(rng() * arr.length)]!
}

function randomPath(rng: () => number): string {
const depth = Math.floor(rng() * 5)
const segments: Array<string> = []
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,
})
}
}
})
})
Loading
Loading