#15704·rspack

[Feature]: reuse compilation dependency snapshots with mutation-safe invalidation

Author: matthewdavis-oaiCreated Sep 15, 2026Updated Sep 16, 2026

Problem and ownership

Rspack's JavaScript compilation dependency facade fetches complete dependency lists repeatedly even when successive consumers inspect the same unchanged compilation. This is distinct from loader-runner dependency insertion (#15679/#15659), Watchpack timestamp traversal (#15680), and native persistent snapshot provenance (#14764/#14765).

In v2.2.4 util/fake.ts, every size/iteration/keys/values/entries operation calls getAllDeps, fetches the full native list and constructs a new Set. has falls through to getDeps().includes when neither a tombstone nor pending addition decides the answer. These are full-list reads even for absent membership queries; has does not itself build the additional JS Set.

Compilation.ts supplies native-backed providers to all four dependency facades. The freshly read main util/fake.ts still has this behavior (Git blob 78561c5635da985ff766df1c6975ec11f7e1aa25, read 2026-09-15); main Compilation.ts blob 53fa1fbdf5086adce565f530b75707c0200828e2 retains the native-backed facade providers. Runtime observations below are v2.2.4, not a current-main benchmark.

Motivation, without claiming a speedup

In three warm component edits on Rspack2.2.4/Rsbuild2.2.6/Node24.19.0/Linux with native watching on both client/server compilers, exclusive sampled main-isolate time in compilation dependency Set materialization was approximately 357/360/353 ms. Stacks include Router dependency snapshots and watcher setup. Profile alignment uncertainty was ±149 ms; boundary sensitivity for this category spans approximately 228–404 ms. These are sampled category costs, not proven removable latency.

A later loader-state implementation A/B/A still showed ~386/405/390 ms in this category while loader-state cost dropped sharply, supporting separate ownership. No application speedup or candidate implementation is claimed for this request.

Self-contained reproduction

Save the script below as rspack-dependency-set-repro.mjs in a standalone directory with stock @rspack/[email protected] installed. Run node rspack-dependency-set-repro.mjs on Node24. It extracts the exact shipped facade and MergeCaller after verifying the package hash; it does not load a compiler or modify the installation. The native-list provider and microtask scheduling are controlled doubles.

Executed successfully on Node24.19.0 against stock Rspack2.2.4: all count and mutation assertions passed. It confirms that three unchanged cycles of size, iteration, keys, values, entries and absent has perform 18 provider calls returning 18*N string entries. This is a deterministic count of provider calls/list entries, not a native boundary timing measurement or count of unique string allocations. Five accesses per cycle also reconstruct a Set by source inspection; has performs a list membership scan.

Mutation assertions cover pending plugin add/addAll before flush, deletion/re-add, asynchronous addition flush, native-side growth/removal without facade mutation, and a local deletion tombstone surviving a flushed pending add. A candidate must also cover iterator-start timing, all dependency kinds, compiler phases, stale compilation handles, concurrent native changes and lifecycle/error paths with real bindings; this small script does not validate those integrations.

Requested capability

Please consider a mutation-safe versioned dependency snapshot or a native membership/iteration API that avoids repeated full-list extraction for unchanged dependency state. A cheap native dependency-generation token could permit reuse only while native generation and local pending-add/delete state are unchanged; the exact API is for maintainer design. Count or membership queries may warrant direct native operations if that preserves semantics and is cheaper than list transfer.

Caching forever per Compilation object is unsafe: plugins and native processing can mutate the sets after an earlier read, and asynchronous MergeCaller flush changes where additions reside. Caching only facade.add/delete mutations is insufficient for native-side growth. Counting only list length also misses replacements. Do not change the native snapshot validity rules or discard watch dependencies as a shortcut.

Potential implementation acceptance: unchanged successive readers avoid repeated full native list transfer; pending/local/native mutations remain visible at the same API boundaries; public ordering/iterator behavior stays compatible; real watch create/delete/context/missing-dependency tests pass. Measure large and small graphs, allocations and end-to-end latency rather than treating the observed category as guaranteed savings.

Related work and limits

#14764/#14765 concern native iterator scope pollution and persistent metadata snapshots, not this JS facade. #14772's current Compilation.ts diff adds invalidation provenance and does not change util/fake.ts; #13095 concerns native module graph iterator APIs, not this facade.

No implementation patch is attached: the missing primitive is mutation provenance/snapshot validity, so proposing an unversioned Set cache would be misleading. The verified fixture establishes repeated provider calls and current mutation semantics; a real-binding implementation and application comparison remain follow-up work.

Verified fixture output

Dependencies Unchanged read cycles Full provider calls String entries returned
1,000 3 18 18,000
4,000 3 18 72,000
16,000 3 18 288,000

All mutation assertions passed. These counts are from the controlled provider, not measured native transfer bytes or unique string allocations.

Complete reproduction script
javascript
// Node 24; resolves an existing local @rspack/[email protected]. No compiler is loaded.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
const require = createRequire(path.join(process.cwd(), 'package.json'));
const entry = require.resolve('@rspack/core');
const source = fs.readFileSync(entry, 'utf8');
const version = require('@rspack/core/package.json').version;
assert.equal(version, '2.2.4');
const sha256 = createHash('sha256').update(source).digest('hex');
assert.equal(sha256, '0f07cb412333bed2d27104a1d0b54ddedecb55efc9d3806648a3e5896bb36ece', 'Exact stock 2.2.4 source required');
const start = source.indexOf('class MergeCaller {');
const finish = source.indexOf('let index_js_namespaceObject', start);
assert(start >= 0 && finish > start);
const fragment = source.slice(start, finish);
assert.equal(fragment.split('function createFakeCompilationDependencies(').length, 2);
// Execute the shipped facade AND MergeCaller, with a controlled microtask queue.
// Only the native dependency provider is a test double.
const microtasks = [];
const create = vm.runInNewContext(fragment + '\ncreateFakeCompilationDependencies;', {
  Set, Array, Object, queueMicrotask: callback => microtasks.push(callback),
});
const flush = () => { while (microtasks.length) microtasks.shift()(); };
const rows = [];
for (const n of [1000, 4000, 16000]) {
  const base = Array.from({ length: n }, (_, i) => `/synthetic/file-${i}.js`);
  let native = new Set(base), getterCalls = 0, stringsReturned = 0;
  const deps = create(() => {
    getterCalls++;
    const snapshot = Array.from(native);
    stringsReturned += snapshot.length;
    return snapshot;
  }, paths => { for (const item of paths) native.add(item); });
  for (let i = 0; i < 3; i++) {
    assert.equal(deps.size, n);
    assert.deepEqual(Array.from(deps), base);
    assert.deepEqual(Array.from(deps.keys()), base);
    assert.deepEqual(Array.from(deps.values()), base);
    assert.deepEqual(Array.from(deps.entries()), base.map(p => [p, p]));
    assert.equal(deps.has('/synthetic/absent.js'), false);
  }
  assert.equal(getterCalls, 18);
  assert.equal(stringsReturned, 18 * n);
  rows.push({ dependencies: n, readCycles: 3, getterCalls, stringsReturned });
  // Future snapshot reuse must retain these existing mutation semantics.
  deps.add('/synthetic/plugin.js');
  assert(deps.has('/synthetic/plugin.js')); // pending add visible before flush
  assert(Array.from(deps).includes('/synthetic/plugin.js'));
  deps.addAll(['/synthetic/extra.js', '/synthetic/plugin.js']);
  assert.equal(deps.delete(base[0]), true);
  assert.equal(deps.delete(base[0]), false);
  assert(!Array.from(deps).includes(base[0]));
  deps.add(base[0]); // clears local deletion
  assert(deps.has(base[0]));
  flush();
  assert(native.has('/synthetic/plugin.js'));
  assert(native.has('/synthetic/extra.js'));
  native.add('/synthetic/native-later.js'); // mutation without facade.add
  assert(deps.has('/synthetic/native-later.js'));
  assert(Array.from(deps).includes('/synthetic/native-later.js'));
  native.delete(base[1]);
  assert(!deps.has(base[1]));
  assert(!Array.from(deps).includes(base[1]));
  deps.add('/synthetic/pending-delete.js');
  assert(deps.delete('/synthetic/pending-delete.js'));
  flush();
  assert(native.has('/synthetic/pending-delete.js'));
  assert(!deps.has('/synthetic/pending-delete.js')); // local tombstone survives flush
  assert(!Array.from(deps).includes('/synthetic/pending-delete.js'));
}
assert.equal(fs.readFileSync(entry, 'utf8'), source);
console.log(JSON.stringify({ version, sha256, scope: 'Actual shipped JS facade; fake native provider and controlled microtask scheduling; no native/compiler execution or timing claims', rows, mutationAssertionsPassed: true }, null, 2));