Skip to content

perf: cache fs probes and precompute route sort keys in scan/deploy paths - #1930

Merged
james-elicx merged 4 commits into
cloudflare:mainfrom
Divkix:perf/cache-scan-fs-probes
Jun 12, 2026
Merged

perf: cache fs probes and precompute route sort keys in scan/deploy paths#1930
james-elicx merged 4 commits into
cloudflare:mainfrom
Divkix:perf/cache-scan-fs-probes

Conversation

@Divkix

@Divkix Divkix commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Closes #1912.

The route scanners and the deploy analyzer repeat deterministic filesystem and parsing work. The route graph is rebuilt on every watcher invalidation in dev, so this lands directly on HMR rebuild latency for larger apps. All three changes preserve output exactly — identical sort order, identical probe results, identical detection booleans — so no existing expectations change.

Implemented as three reviewable commits:

1. perf(routing): precompute route precedence before sorting

compareRoutes re-parsed every pattern on each comparison, so Array.prototype.sort called routePrecedence ~2·log n times per route. Replaced with sortRoutes, which scores each pattern once into a Map and sorts by (score, pattern.localeCompare). The localeCompare tiebreaker already guarantees a total order, so ordering is byte-identical — this just drops the redundant re-parsing on every scan (routing/utils.ts, callers in pages-router.ts and app-route-graph.ts).

2. perf(app-router): cache convention-file probes per route-graph scan

buildAppRouteGraph walks the appDir→leaf chain separately for every route (layouts, templates, errors, boundaries, slots), each step probing up to 4 extensions per convention file via existsSync. Shared ancestors — the app/ root above all — were re-stat'd once per descendant route. Memoized findFile through a WeakMap<ValidFileMatcher, Map> keyed by a per-scan matcher clone. Results are immutable within one scan, so no invalidation is needed: the cache lives exactly as long as the scan and is GC'd afterward. The fresh per-scan key keeps overlapping scans isolated, and the null not-found outcome is cached too.

3. perf(deploy): single tree walk for detection and reuse parsed package.json

analyzeProject walked the app/ tree twice (detectISR + detectMDX) and detectNativeModules re-read/re-parsed package.json that was already parsed into allDeps. Merged the two walkers into one scanTreeForDetection(dir, { isr, mdx }) that evaluates both predicates per entry with each flag short-circuiting independently, extracted detectMDXFromConfig, added resolveProjectDir for the shared root/src precedence, and passed the already-merged allDeps into detectNativeModules. Same files visited, same patterns tested, same booleans out.

Tests

  • tests/route-sorting.test.ts: sortRoutes unit tests — documented precedence ordering, in-place/same-reference, idempotency, decorate-sort parity, empty/single-element edges.
  • tests/app-route-graph.test.ts: sibling-route case asserting shared-ancestor layouts and nearest boundaries resolve identically (exercises the probe cache incl. the null-miss path).
  • tests/deploy.test.ts: combined ISR+MDX single-walk case and a devDependencies-only native-module case.

All touched suites pass locally and vp check (format, lint, types) is clean.

Divkix added 3 commits June 11, 2026 17:46
`compareRoutes` re-parsed every pattern on each comparison, so a sort
called `routePrecedence` ~2·log n times per route instead of once. Replace
it with `sortRoutes`, which scores each pattern once into a Map and sorts
by (score, pattern.localeCompare). The localeCompare tiebreaker already
guarantees a total order, so ordering is byte-identical; this just removes
the redundant re-parsing on every route scan (dev re-scans on file change,
plus the pages/api and app-graph builds).
`buildAppRouteGraph` walks the appDir→leaf chain separately for every
route (layouts, templates, errors, boundaries, slots), each step probing
up to 4 extensions per convention file via `existsSync`. Shared ancestor
directories — the `app/` root above all — were re-stat'd once per
descendant route, so a scan cost roughly routes × depth × conventions ×
extensions syscalls.

Memoize `findFile` through a `WeakMap<ValidFileMatcher, Map>` keyed by a
per-scan matcher clone created in `buildAppRouteGraph`. Results are
immutable within one scan, so no invalidation is needed: the cache lives
exactly as long as the scan and is GC'd afterward. The fresh per-scan key
also keeps overlapping scans isolated, and the `null` not-found outcome is
cached too. Probe results are identical to the previous direct `existsSync`
calls.
….json

`analyzeProject` walked the app/ tree twice — once in `detectISR` and again
in `detectMDX` — and `detectNativeModules` re-read and re-parsed
package.json that `detectProject` had already parsed into `allDeps`.

Merge the two walkers into one `scanTreeForDetection(dir, { isr, mdx })`
that evaluates both predicates per entry with each flag short-circuiting
independently, extract the next.config MDX check into `detectMDXFromConfig`,
and add `resolveProjectDir` for the shared root/`src` precedence. Pass the
already-merged `allDeps` into `detectNativeModules`. Detection semantics are
unchanged: same files visited, same patterns tested, same booleans out.
@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1930
npm i https://pkg.pr.new/vinext@1930

commit: a844add

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: perf optimizations in scan/deploy paths

Reviewed all three changes for behavioral parity, which is the central claim of this PR. The refactors preserve output exactly, are well-documented, and are backed by targeted tests. Ran the touched suites locally — all green:

  • tests/route-sorting.test.ts (47), tests/deploy.test.ts (254), tests/app-route-graph.test.ts (55), tests/routing.test.ts (112) all pass
  • vp check clean on the three core files (format, lint, types)

1. sortRoutes (routing/utils.ts)

Decorate-sort is correct. routePrecedence is a pure function of pattern, scores are computed once, and the localeCompare tiebreaker preserves the total order, so ordering is byte-identical to the old comparator. compareRoutes has no remaining references anywhere (grepped). The decorate-sort-parity test with shuffled input is a good guard.

2. Per-scan fs-probe cache (routing/app-route-graph.ts)

Correct. findFileWithExts depends only on (dir, name, matcher.dottedExtensions); the clone shares the same dottedExtensions, and the cache key ${dir}\0${name} is deterministic. The undefined vs null distinction for cache-miss-vs-cached-miss is handled properly. Per-scan key cloning correctly isolates overlapping async scans, and the no-cache fallback path (exercised by direct helper tests) is identical to the prior findFile = findFileWithExts. The matcher is threaded consistently to every findFile/findSlotSubPages call from the entry points. Tightening findSlotSubPagesCache to the per-scan clone is a strict improvement over the prior per-build keying.

3. Single tree walk + reused package.json (deploy.ts)

Boolean outputs are equivalent: found.isr/found.mdx flip true exactly when a matching entry exists anywhere in the tree, matching the old early-return walkers. Config-first MDX precedence is preserved (mdx: !hasMDX skips the redundant fs MDX scan when config already matched, while ISR still scans). resolveProjectDir matches the old root→src/ fallback. detectNativeModules(allDeps) produces the same result as the old self-parsing version even when package.json is missing/malformed (both yield []/{}). The devDeps-only test is a nice addition.

No correctness or parity issues found. One trivial note below.

}
}
return routes.sort((a, b) => {
const diff = (scores.get(a.pattern) ?? 0) - (scores.get(b.pattern) ?? 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (non-blocking): every pattern in routes is inserted into scores in the pre-pass, so scores.get(a.pattern)/scores.get(b.pattern) can never be undefined here. The ?? 0 fallback is dead-defensive — harmless, but a reader may wonder whether a missing-score path is reachable. Fine to leave as-is.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully. Here's a summary of my findings:

Review Summary — PR #1930

Verdict: Approved. This is a clean, well-tested perf refactor with behavioral parity preserved across all three changes. I verified each claim and ran the touched test suites locally (468 tests pass) plus vp check (clean).

What I verified

1. sortRoutes (decorate-sort)routePrecedence is pure in pattern, scores computed once, localeCompare tiebreaker preserves the total order → byte-identical output. Confirmed no remaining compareRoutes references via grep.

2. Per-scan fs-probe cachefindFileWithExts depends only on (dir, name, matcher.dottedExtensions); the clone shares dottedExtensions, key is deterministic, and the undefined/null miss distinction is handled. Per-scan key isolates overlapping async scans; the no-cache fallback is identical to the prior findFile = findFileWithExts. Matcher threaded consistently. Tightening findSlotSubPagesCache to the per-scan clone is a strict improvement.

3. Single tree walk + reused package.json — Boolean outputs equivalent to the old early-return walkers; config-first MDX precedence preserved; resolveProjectDir matches the old root→src/ fallback; detectNativeModules(allDeps) yields the same result even with missing/malformed package.json.

Notes

  • One trivial inline note: the ?? 0 fallback in sortRoutes is dead-defensive (every pattern is always inserted into the score map). Non-blocking.

No correctness or parity issues found.

github run

@james-elicx
james-elicx merged commit cd921ab into cloudflare:main Jun 12, 2026
42 checks passed
@Divkix
Divkix deleted the perf/cache-scan-fs-probes branch August 10, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(build): cache fs probes and precompute route sort keys in scan/deploy paths

2 participants