Skip to content

fix(watchpack): normalize backslashes in withoutCase for Windows pers…#14722

Open
urielvan wants to merge 1 commit into
web-infra-dev:mainfrom
urielvan:fix/watchpack-normalize-path-separator-windows
Open

fix(watchpack): normalize backslashes in withoutCase for Windows pers…#14722
urielvan wants to merge 1 commit into
web-infra-dev:mainfrom
urielvan:fix/watchpack-normalize-path-separator-windows

Conversation

@urielvan

@urielvan urielvan commented Jul 8, 2026

Copy link
Copy Markdown

Summary

On Windows with persistent build cache, hot start emits spurious removed events for unchanged source files, cascading into a full rebuild each launch. The root cause is in vendored watchpack's withoutCase: it lowercases path strings for map lookups but does not normalize path separators. On Windows the same physical file can reach watchpack under two different string spellings—forward slash from rspack's persistent-cache rehydrate (where PortablePath normalizes for cross-platform portability), backslash from native fs and loader addDependency. DirectoryWatcher.doScan's initial-missing map ends up keyed by whichever slash flavor the caller passed in, then the delete pass keyed by path.join()'s native-separator output no longer matches, and every survivor watcher fires a spurious initial-missing.

This PR normalizes backslashes to forward slashes inside withoutCase, collapsing the two flavors to a single map key. The patch is registered in packages/rspack/prebundle.config.js's watchpack afterBundle so it re-applies automatically whenever pnpm run prebundle syncs upstream watchpack.

Repro (before this PR)

  • Trigger (independent of front-end framework): any rspack build on Windows with experiments.cache: { type: 'persistent' } where the compilation's file / context dependency sets contain paths with mixed separator flavors after hot-start cache rehydrate.
  • Amplifier: build inputs that push many entries through loader-side this.addDependency. CSS Modules is a typical one—every *.module.{css,less,scss,sass} file plus their generated .d.ts companions land in fileDependencies, so a project with a modest number of CSS Modules files reproduces reliably on the first hot start.
  • Observed symptom: building removed ... and N more on the second launch, followed by a full rebuild triggered by watchpack's cascaded initial-missing events.

Reproduces stably on Windows; does not reproduce on macOS / Linux (POSIX paths never carry \, so the map key mismatch cannot happen).

Root cause

packages/rspack/compiled/watchpack/index.js (vendored webpack/watchpack@2.x):

// line ~1037, before this PR
function withoutCase(str) {
	return str.toLowerCase();
}

Used at DirectoryWatcher.doScan() line ~1835:

const missingWatchers = new Map(this.watchers);   // key = withoutCase(target from callers, arbitrary slash)
missingWatchers.delete(withoutCase(this.path));
for (const item of itemPaths) {                    // itemPaths = path.join(this.path, ...) => backslash on Windows
	missingWatchers.delete(withoutCase(item));       // backslash delete against forward-slash keys => no match
}
for (const watchers of missingWatchers.values()) {
	for (const watcher of watchers) {
		watcher.emit('initial-missing', 'scan (missing in initial scan)');
	}
}

When some watcher keys carry forward slash (typically from rspack's persistent-cache rehydrate path where PortablePath normalizes to forward slash for cross-platform portability) and path.join produces backslash, every forward-slash-keyed watcher survives the delete pass and fires a spurious initial-missing, propagating through:

watchpack → NativeWatchFileSystem → Watching.#mergeWithCollected → compiler.removedFiles → rebuild

Fix

Normalize backslashes to forward slashes inside withoutCase, so map lookups are stable regardless of the separator flavor the caller passes:

function withoutCase(str) {
	return str.toLowerCase().replace(/\\/g, "/");
}

The patch is applied in packages/rspack/prebundle.config.js's watchpack afterBundle via the existing replaceFileContent helper (same pattern already used for the d.ts adjustment right above), so upstream syncs re-apply it automatically.

Performance impact

Microbench (Node 20 / Windows, mixed backslash + forward-slash paths, 5000 calls):

Version Total Per-call
.toLowerCase() 0.234 ms 0.047 μs
.toLowerCase().replace(/\\/g, '/') 1.316 ms 0.263 μs

Extra ~0.2 μs per call. A hot start touches withoutCase at most a few thousand times, so the added cost is ~2 ms per hot start against a 3-4 s total—well within noise.

No indexOf guard: guarding actually made the benchmark slower on real Windows paths (which frequently contain \) because the guard's indexOf walk can't short-circuit and the branch still has to run replace anyway.

Test / verification

Verified against the following stack on Windows 11:

  • rspack: 2.1.2 (same patch applied to node_modules/@rspack/core/compiled/watchpack/index.js)
  • Modern.js: 3.5.0
  • Node.js: 20 LTS

Ran 100 consecutive npm start → wait 15 s → taskkill /F /T cycles:

Metric Value
Total attempts 100
Successful hot starts (built in X.XXs within 15 s) 100
Cold start (attempt 1) 5.26 s
Hot start p50 / p95 / max (attempts 2–100) 3.05 s / 3.66 s / 4.41 s
Hot start avg (attempts 2–100) 3.12 s
Spurious building removed ... and N more in stdout none

Hot start performance matches macOS / Linux.

Notes

  • Cross-platform impact: on POSIX, .replace(/\\/g, '/') matches nothing and is a near-noop
  • Does not modify rspack Rust code
  • Vendored packages/rspack/compiled/watchpack/index.js is .gitignored, so the visible diff is only prebundle.config.js; the vendored file gets regenerated with the patched form on next pnpm run prebundle

@urielvan urielvan requested a review from hardfist as a code owner July 8, 2026 02:59
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.

1 participant