perf(migrate): collect Vitest source signals in one traversal - #2541
Open
m0g3r wants to merge 1 commit into
Open
perf(migrate): collect Vitest source signals in one traversal#2541m0g3r wants to merge 1 commit into
m0g3r wants to merge 1 commit into
Conversation
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
✅ Deploy Preview for viteplus-preview canceled.
|
Member
|
Could you show a before and after speed comparison in the PR description? |
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):
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2420
Problem
vp migratederives several independent boolean signals from a package's source tree — browser mode, retained upstreamvitestmodule references, and one source-scan per opt-in browser provider. Each ran its own full recursive traversal viasourceTreeMatches, 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
sourceTreeMatchesis generalized tosourceTreeMatchesEach, 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.sourceTreeMatchesbecomes a one-predicate wrapper over it, so there is still a single traversal implementation.collectPackageSourceScanSignalsenrolls all the per-package signals — browser mode, both provider scans, and the retained-vitest-module scan — in that one pass.rewriteStandaloneProjectandrewriteMonorepoProject.providerSourceModesalready holds that result.Two deliberate limits:
sourceTreeReferencesRetainedVitestModulereturns 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.rewriteMonorepoProjectperforms config merging between them, which may create or modifyvite.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.
a583efa)6e88cbb)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
rewriteStandaloneProjectperforms it:collectProviderSourceModes(one traversal per opt-in provider = 2) +usesVitestBrowserMode(1) +sourceTreeReferencesRetainedVitestModule(1) + the redundantusesWebdriverioProviderrescan in thepackage.jsoncallback (1) = 5 traversals;collectPackageSourceScanSignals= 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.tscannot be imported on its own in a bare checkout: it importsprojectUsesVitestDirectlyfrom the../migrator.tsbarrel, which transitively reaches the native NAPI binding. That import — along withreadPackageJsonIfExistsandWorkspacePackage— is used only byworkspaceUsesVitestDirectly, which is not on the per-package scan path.So the benchmark loads each revision's
source-scan.tswith its first 40 lines (the import block plus that one function) replaced by a prelude that inlines the three constants it needs fromshared.tsand a JSON-parsing stand-in forhasVitestTypesInTsconfig. Everything from line 41 onward is byte-identical to the shipped module at that revision (verified withdiff), so every measured function is the real one. ThehasVitestTypesInTsconfigstand-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 backfalse, 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 migrationcountsfs.readFileSynccalls 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:
source-scan.tsandorchestrators.tsreverted tomainand the test kept, it fails:AssertionError: expected 5 to be 1— independently reproducing the issue's measured standalone count;Checks actually run on this branch:
vitest run packages/cli/src/migration— 409 passed across 10 filesvitest run(full unit suite) — 1023 passed, 1 skipped; 7 pre-existing snapshot failures inpackages/prompts/src/__tests__/render.spec.ts, identical on unmodifiedmainin 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 unmodifiedmainhere (87 pre-existing errors from unbuilt docs/workspace packages, none in the touched files)vp lint --type-aware --type-check— clean for the touched filesvp fmt --check— cleanNot 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