Skip to content

perf(migrate): collect Vitest source signals in one traversal - #2541

Open
m0g3r wants to merge 1 commit into
voidzero-dev:mainfrom
m0g3r:perf/2420-single-source-tree-scan
Open

perf(migrate): collect Vitest source signals in one traversal#2541
m0g3r wants to merge 1 commit into
voidzero-dev:mainfrom
m0g3r:perf/2420-single-source-tree-scan

Conversation

@m0g3r

@m0g3r m0g3r commented Aug 23, 2026

Copy link
Copy Markdown

Closes #2420

Problem

vp migrate derives several independent boolean signals from a package's source tree — browser mode, retained upstream vitest module references, and one source-scan per opt-in browser provider. Each ran its own full recursive traversal via sourceTreeMatches, reading every eligible TS/JS file once per signal.

On the complete-miss path (no browser/provider references, no tsconfig retaining Vitest types, no webdriverio dependency to short-circuit) that is 5 reads per source file for a standalone project, exactly as the issue reports.

Changes

  • sourceTreeMatches is generalized to sourceTreeMatchesEach, which evaluates several content predicates in a single pass: each file is read once and offered to every predicate that has not yet matched, unwinding as soon as all are decided. sourceTreeMatches becomes a one-predicate wrapper over it, so there is still a single traversal implementation.
  • New collectPackageSourceScanSignals enrolls all the per-package signals — browser mode, both provider scans, and the retained-vitest-module scan — in that one pass.
  • Both per-package call sites use it: rewriteStandaloneProject and rewriteMonorepoProject.
  • The standalone path stops rescanning the tree for the webdriverio provider. As the issue notes, providerSourceModes already holds that result.

Two deliberate limits:

  • The tsconfig short-circuit is preserved. sourceTreeReferencesRetainedVitestModule returns early when a tsconfig already settles the signal; in the combined scan that predicate is simply not enrolled in that case, so the tree is not walked for it.
  • Results are not shared between the workspace and package phases of a monorepo migration. The issue points out that rewriteMonorepoProject performs config merging between them, which may create or modify vite.config.ts, so cross-phase reuse would need a separate correctness argument. This PR consolidates within each phase only — the fallback the issue describes as safe. The workspace phase's own per-package scans are left for a follow-up.

Per-predicate semantics are unchanged: same traversal order, same skip directories, same nested-package boundary, same "unreadable file is ignored and scanning continues" behaviour.

Performance

Measured on the full-miss path — the worst case, and the one #2420 reports: a standalone project with no browser/provider references anywhere, no tsconfig retaining Vitest types, and no webdriverio dependency to short-circuit a scan.

source files before (a583efa) after (6e88cbb) speedup reads per source file
250 6.58 ms 2.28 ms 2.9× 5 → 1
1,000 24.04 ms 7.21 ms 3.3× 5 → 1
4,000 97.07 ms 29.08 ms 3.3× 5 → 1

Each cell is the median of 15 timed iterations after 3 warmups, and each row is the median of three such rounds; run-to-run spread stayed under 8%. Node v22.22.2, Linux x64, 4-core Xeon @ 2.10 GHz, warm page cache.

What is timed is the per-package scan phase exactly as each revision's rewriteStandaloneProject performs it:

  • beforecollectProviderSourceModes (one traversal per opt-in provider = 2) + usesVitestBrowserMode (1) + sourceTreeReferencesRetainedVitestModule (1) + the redundant usesWebdriverioProvider rescan in the package.json callback (1) = 5 traversals;
  • aftercollectPackageSourceScanSignals = 1 traversal.

The measured read counts came out at exactly 5 per source file before and 1 after, independently reproducing the amplification the issue reports and matching the regression test's expected 5 to be 1.

Wall time falls ~3.3× rather than the full 5× because only the read and UTF-8 decode per file are removed — each file's content is still matched against the same predicates either way. These are warm-page-cache numbers, so they understate the gain on a cold cache.

How this was measured

source-scan.ts cannot be imported on its own in a bare checkout: it imports projectUsesVitestDirectly from the ../migrator.ts barrel, which transitively reaches the native NAPI binding. That import — along with readPackageJsonIfExists and WorkspacePackage — is used only by workspaceUsesVitestDirectly, which is not on the per-package scan path.

So the benchmark loads each revision's source-scan.ts with its first 40 lines (the import block plus that one function) replaced by a prelude that inlines the three constants it needs from shared.ts and a JSON-parsing stand-in for hasVitestTypesInTsconfig. Everything from line 41 onward is byte-identical to the shipped module at that revision (verified with diff), so every measured function is the real one. The hasVitestTypesInTsconfig stand-in is never actually invoked, because the fixture contains no tsconfig.

The fixture is generated: N plausible ~1.4 KB TS modules, 20 per directory under src/, none containing any scan hint string. Before timing, each run asserts that every signal came back false, so a fixture that accidentally short-circuited a traversal would fail rather than silently produce a flattering number.

Testing

New regression test reads each source file once during a standalone migration counts fs.readFileSync calls against a full-miss fixture and asserts each source file is read once. It also asserts every file is read at least once, so a fixture that stopped exercising the scan would fail rather than silently pass.

Verified in both directions:

  • with source-scan.ts and orchestrators.ts reverted to main and the test kept, it fails: AssertionError: expected 5 to be 1 — independently reproducing the issue's measured standalone count;
  • with the change applied, it passes.

Checks actually run on this branch:

  • vitest run packages/cli/src/migration — 409 passed across 10 files
  • vitest run (full unit suite) — 1023 passed, 1 skipped; 7 pre-existing snapshot failures in packages/prompts/src/__tests__/render.spec.ts, identical on unmodified main in this environment (verified by re-running on a clean tree: 1022 passed with the same 7 failures)
  • tsgo -b tsconfig.json — error set byte-identical to unmodified main here (87 pre-existing errors from unbuilt docs/workspace packages, none in the touched files)
  • vp lint --type-aware --type-check — clean for the touched files
  • vp fmt --check — clean

Not run: the PTY snapshot suite and ecosystem e2e. This change alters no CLI output, only how many times the same files are read.

AI assistance

Claude Opus 5 wrote the implementation, the test, the benchmark, and this description. The change is agent-authored and has not had a separate human review. The results quoted above are from actual runs, not estimates.


Generated by Claude Code

The migration derives several independent boolean signals from a package's
source tree — browser mode, retained upstream `vitest` module references, and
one source-scan per opt-in browser provider. Each ran its own full recursive
traversal, reading every eligible TS/JS file once per signal, so a standalone
project on the complete-miss path read each source file 5 times.

`sourceTreeMatches` is generalized to `sourceTreeMatchesEach`, which evaluates
several content predicates in a single pass: each file is read once and offered
to every predicate that has not yet matched, unwinding as soon as all are
decided. `collectPackageSourceScanSignals` enrolls all the per-package signals
that way, and both per-package call sites in the orchestrators use it.

The tsconfig `types` short-circuit is preserved: when a tsconfig already settles
the retained-module signal, its source predicate is not enrolled at all. The
standalone path also stops rescanning the tree for the webdriverio provider,
since `providerSourceModes` already holds that result.

Per-predicate semantics are unchanged — same traversal order, skip directories,
nested-package boundary, and unreadable-file handling. Results are deliberately
not shared between the workspace and package phases of a monorepo migration,
since config merging runs between them.

Closes voidzero-dev#2420
@netlify

netlify Bot commented Aug 23, 2026

Copy link
Copy Markdown

Deploy Preview for viteplus-preview canceled.

Name Link
🔨 Latest commit 6e88cbb
🔍 Latest deploy log https://app.netlify.com/projects/viteplus-preview/deploys/6a8b087179db3c0008aad289

@fengmk2

fengmk2 commented Aug 24, 2026

Copy link
Copy Markdown
Member

Could you show a before and after speed comparison in the PR description?

m0g3r commented Aug 24, 2026

Copy link
Copy Markdown
Author

Added a Performance section to the description with before/after numbers.

Summary, on the full-miss path #2420 describes (median of 15 iterations × 3 rounds, Node 22.22.2, warm cache):

source files before after speedup
250 6.58 ms 2.28 ms 2.9×
1,000 24.04 ms 7.21 ms 3.3×
4,000 97.07 ms 29.08 ms 3.3×

Reads per source file go from exactly 5 to exactly 1, which independently reproduces the amplification the issue reports. Wall time improves ~3.3× rather than 5× because only the read and decode per file are eliminated — the predicates still run over each file's content either way.

The description also has a collapsed note on exactly what was timed and how, since the scan module needs its barrel import removed to load without the native binding.


Generated by Claude Code

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(migrate): Vitest signal detection repeatedly traverses package source trees

2 participants