From ac243f0cd355786f31fcc478e7ab1da1814171ef Mon Sep 17 00:00:00 2001 From: Ross Brandes Date: Thu, 1 Oct 2026 23:49:59 -0400 Subject: [PATCH 1/3] Only re-evaluate ESM modules that can see a mock Node can't evict modules from its ESM cache, so quibble gets a fresh instance by importing under a new `?__quibble=` URL. It tagged every import that way, so each new generation re-evaluated (and permanently retained) the whole graph of everything imported while any mock existed, including modules unrelated to the mock. In a suite that imports its subject in a beforeEach this grows without bound. Learn the import graph from the resolve and load hooks instead, and only tag a module that is mocked or can reach a mocked module. A module's imports are only known once it has finished linking, so until it was first loaded in an earlier generation it is assumed to be affected. Unaffected modules are then evaluated about twice (tagged, then at their plain URL) instead of once per generation. Nothing parses source, and dynamic imports need no special handling because they run through the resolve hook when they execute. --- lib/import-graph.js | 133 ++++++++++++++++++ lib/quibble-sync-hooks.js | 28 +++- lib/quibble.mjs | 30 +++- .../import-graph-missing-module/importer.mjs | 2 + .../import-graph-stub-first/leaf.mjs | 1 + .../import-graph-stub-first/middle.mjs | 2 + .../import-graph-stub-first/subject.mjs | 2 + test/esm-fixtures/import-graph/leaf.mjs | 1 + test/esm-fixtures/import-graph/middle.mjs | 2 + test/esm-fixtures/import-graph/subject.mjs | 5 + test/esm-fixtures/import-graph/unrelated.mjs | 3 + .../esm-lib/quibble-esm-import-graph.test.mjs | 73 ++++++++++ 12 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 lib/import-graph.js create mode 100644 test/esm-fixtures/import-graph-missing-module/importer.mjs create mode 100644 test/esm-fixtures/import-graph-stub-first/leaf.mjs create mode 100644 test/esm-fixtures/import-graph-stub-first/middle.mjs create mode 100644 test/esm-fixtures/import-graph-stub-first/subject.mjs create mode 100644 test/esm-fixtures/import-graph/leaf.mjs create mode 100644 test/esm-fixtures/import-graph/middle.mjs create mode 100644 test/esm-fixtures/import-graph/subject.mjs create mode 100644 test/esm-fixtures/import-graph/unrelated.mjs create mode 100644 test/esm-lib/quibble-esm-import-graph.test.mjs diff --git a/lib/import-graph.js b/lib/import-graph.js new file mode 100644 index 0000000..c5399c5 --- /dev/null +++ b/lib/import-graph.js @@ -0,0 +1,133 @@ +// Keeps `quibble.esm()` from re-evaluating every module on every mock change. +// +// Node can't evict anything from its ESM module cache, so the only way to get +// a fresh instance of a module is to import it under a new URL. Quibble does +// that by tagging URLs with `?__quibble=`, where the generation +// goes up whenever a mock is added or reset. Tagging *every* module that way +// means each generation re-evaluates, and permanently retains, the whole +// graph of every module imported while any mock exists - including packages +// that have nothing to do with the mock. +// +// Only a module that is mocked, or that can reach a mocked module through its +// imports, needs a fresh URL. This learns those imports from what the +// resolve and load hooks already see (no source parsing, so it behaves the +// same for TypeScript or anything else a loader transforms): +// +// - recordImport() notes each parent -> child edge the resolve hook sees +// - recordLoad() notes the generation in which a module was first loaded +// - needsQuibbling() decides whether a resolved URL should be tagged +// +// Edges are recorded for every import, including ones made while nothing is +// mocked and ones whose target doesn't exist on disk (a module can be mocked +// without existing). Modules loaded earlier count as fully known by the time +// a mock exists, so a missing edge would make a module look like it depends +// on nothing, and it would never see a mock added later. For the same reason, +// loads served as a mock stub are not recorded: a stub has no imports, so +// counting one would make the real module look fully known with no edges. +// +// A module's imports are only fully known once it has finished linking, which +// we take to mean "it was first loaded in an earlier generation". Until then +// it is assumed to be affected and gets tagged, exactly as before. So each +// unaffected module is evaluated about twice (once tagged, once at its plain +// URL) instead of once per generation. +// +// Dynamic `import()` calls need no special handling: they run through the +// resolve hook when they execute, so they see whichever mocks exist then. +// `require()` edges inside CommonJS aren't seen by the async hooks, so a +// CommonJS module is treated as a leaf. +// +// Modules are identified by URL with any query string and hash removed, the +// same way mocks are keyed, so `a.js?v=1` and `a.js?v=2` (separate instances +// in Node) are one node here and get one tagging decision. +// +// One consequence for users: a module that no mock can reach is now a single +// instance for the life of the process, where it used to get a fresh one +// after every mock change. Module-level state in such a module (counters, +// caches, singletons) persists between tests. + +const { stripQueryAndHash } = require('./loader-helpers') + +/** + * Relies on every mock change bumping `state.stubModuleGeneration` (the + * decisions are memoised per generation, and "known" means loaded in an + * earlier generation), and on reset() replacing `state.quibbledModules` + * with a new Map. Those updates live in the message handler in quibble.mjs + * (`globalPreload`), in thisWillRunInUserThread.js (same-thread state), and + * nowhere else. + * + * @param {import('./loader-helpers').QuibbleLoaderState} state - read on every + * call, never cached, because reset() replaces `state.quibbledModules` + */ +exports.createImportGraph = function createImportGraph (state) { + /** @type {Map>} plain URL -> plain URLs it imports */ + const imports = new Map() + /** @type {Map} plain URL -> generation of its first load */ + const firstLoadedIn = new Map() + let decisionsGeneration = -1 + /** @type {Map} memoised per generation, so one URL never gets two answers */ + let decisions = new Map() + + function recordImport (parentUrl, childUrl) { + if (!parentUrl) { + return + } + const parent = stripQueryAndHash(parentUrl) + if (!imports.has(parent)) { + imports.set(parent, new Set()) + } + imports.get(parent).add(stripQueryAndHash(childUrl)) + } + + function recordLoad (url) { + const plainUrl = stripQueryAndHash(url) + if (!firstLoadedIn.has(plainUrl)) { + firstLoadedIn.set(plainUrl, state.stubModuleGeneration) + } + } + + function needsQuibbling (resolvedUrl) { + if (!state.quibbledModules.size) { + return false + } + + const generation = state.stubModuleGeneration + if (decisionsGeneration !== generation) { + decisionsGeneration = generation + decisions = new Map() + } + + const url = stripQueryAndHash(resolvedUrl) + if (!decisions.has(url)) { + decisions.set(url, mayReachMockedModule(url, generation)) + } + return decisions.get(url) + } + + function mayReachMockedModule (startUrl, generation) { + const seen = new Set([startUrl]) + const queue = [startUrl] + while (queue.length) { + const url = queue.shift() + if (state.quibbledModules.has(url)) { + return true + } + if (url.startsWith('node:')) { + continue + } + + const loadedIn = firstLoadedIn.get(url) + if (loadedIn === undefined || loadedIn >= generation) { + return true // its imports might not all be known yet + } + for (const child of imports.get(url) ?? []) { + if (!seen.has(child)) { + seen.add(child) + queue.push(child) + } + } + } + return false + } + + return { recordImport, recordLoad, needsQuibbling } +} diff --git a/lib/quibble-sync-hooks.js b/lib/quibble-sync-hooks.js index 94a5788..67508b4 100644 --- a/lib/quibble-sync-hooks.js +++ b/lib/quibble-sync-hooks.js @@ -11,11 +11,14 @@ const { stubbedLoadResult, stripQueryAndHash } = require('./loader-helpers') +const { createImportGraph } = require('./import-graph') /** * @param {import('./loader-helpers').QuibbleLoaderState} state */ exports.createSyncHooks = function createSyncHooks (state) { + const importGraph = createImportGraph(state) + function resolve (specifier, context, nextResolve) { // registerHooks() hooks also see CommonJS require() calls, which // register()-style hooks never did. Quibble's ESM cache-busting query @@ -34,20 +37,41 @@ exports.createSyncHooks = function createSyncHooks (state) { context.__quibbleSuppressed = true try { const result = nextResolve(plan.nextSpecifier, context) + if (plan.kind === 'quibble') { + // Same off-thread-loader caveat as below: work with untagged URLs + const url = removeQuibbleQuery(result.url) + importGraph.recordImport(context.parentURL && removeQuibbleQuery(context.parentURL), url) + if (!importGraph.needsQuibbling(url)) { + return { ...result, url } + } + } // An explicit `--loader=quibble` can be registered (off-thread) // alongside these hooks and will already have tagged the resolved URL; // strip it before quibble re-tags it with its own generation number. // The async hooks never run alongside another quibble instance. return finishResolve(state, plan, result, removeQuibbleQuery) } catch (error) { - return recoverResolve(plan, error) + const recovered = recoverResolve(plan, error) + // The import still happened (the module is just mocked without existing), + // so the importer has to be seen as depending on the mock + if (plan.kind === 'quibble') { + importGraph.recordImport(context.parentURL, recovered.url) + } + return recovered } finally { context.__quibbleSuppressed = false } } function load (url, context, nextLoad) { - return stubbedLoadResult(state, url) ?? nextLoad(stripQueryAndHash(url), context) + const stub = stubbedLoadResult(state, url) + if (stub) { + return stub + } + + // Only real loads count (see the async load hook in quibble.mjs) + importGraph.recordLoad(url) + return nextLoad(stripQueryAndHash(url), context) } return { resolve, load } diff --git a/lib/quibble.mjs b/lib/quibble.mjs index b53fcb1..c8494b5 100644 --- a/lib/quibble.mjs +++ b/lib/quibble.mjs @@ -1,6 +1,7 @@ import quibble from './quibble.js' import { thisWillRunInUserThread } from './thisWillRunInUserThread.js' import helpers from './loader-helpers.js' +import importGraphModule from './import-graph.js' const { planResolve, @@ -28,13 +29,15 @@ const quibbleLoaderState = { quibbledModules: new Map(), stubModuleGeneration: 0 } +const importGraph = importGraphModule.createImportGraph(quibbleLoaderState) // The decision logic - what to do with a given specifier/URL - lives in // planResolve()/finishResolve()/recoverResolve() (loader-helpers.js), shared // with the sync hooks in quibble-sync-hooks.js. This is just the async // driver for it: decide the plan, call nextResolve (awaited, suppressed // unless the plan says not to), and let finishResolve/recoverResolve -// interpret the outcome. +// interpret the outcome. Modules that can't reach a mock keep their plain URL +// (see import-graph.js) so they stay in Node's module cache. export async function resolve (specifier, context, nextResolve) { const plan = planResolve(quibbleLoaderState, specifier, context) @@ -45,9 +48,21 @@ export async function resolve (specifier, context, nextResolve) { context.__quibbleSuppressed = true try { const result = await nextResolve(plan.nextSpecifier, context) + if (plan.kind === 'quibble') { + importGraph.recordImport(context.parentURL, result.url) + if (!importGraph.needsQuibbling(result.url)) { + return result + } + } return finishResolve(quibbleLoaderState, plan, result) } catch (error) { - return recoverResolve(plan, error) + const recovered = recoverResolve(plan, error) + // The import still happened (the module is just mocked without existing), + // so the importer has to be seen as depending on the mock + if (plan.kind === 'quibble') { + importGraph.recordImport(context.parentURL, recovered.url) + } + return recovered } finally { context.__quibbleSuppressed = false } @@ -62,8 +77,15 @@ export async function resolve (specifier, context, nextResolve) { * @returns {Promise<{ source: !(string | SharedArrayBuffer | Uint8Array), format: string}>} */ export async function load (url, context, nextLoad) { - return stubbedLoadResult(quibbleLoaderState, url) ?? - await nextLoad(stripQueryAndHash(url), context) + const stub = stubbedLoadResult(quibbleLoaderState, url) + if (stub) { + return stub + } + + // Only real loads count: a stub has no imports, so counting it would make + // the module look fully known with no edges the next time it loads for real + importGraph.recordLoad(url) + return nextLoad(stripQueryAndHash(url), context) } export const globalPreload = ({ port }) => { diff --git a/test/esm-fixtures/import-graph-missing-module/importer.mjs b/test/esm-fixtures/import-graph-missing-module/importer.mjs new file mode 100644 index 0000000..00235f9 --- /dev/null +++ b/test/esm-fixtures/import-graph-missing-module/importer.mjs @@ -0,0 +1,2 @@ +import { ghost } from './does-not-exist.mjs' +export const run = () => ghost() diff --git a/test/esm-fixtures/import-graph-stub-first/leaf.mjs b/test/esm-fixtures/import-graph-stub-first/leaf.mjs new file mode 100644 index 0000000..f4dd60b --- /dev/null +++ b/test/esm-fixtures/import-graph-stub-first/leaf.mjs @@ -0,0 +1 @@ +export const thing = () => 'real' diff --git a/test/esm-fixtures/import-graph-stub-first/middle.mjs b/test/esm-fixtures/import-graph-stub-first/middle.mjs new file mode 100644 index 0000000..0b176d1 --- /dev/null +++ b/test/esm-fixtures/import-graph-stub-first/middle.mjs @@ -0,0 +1,2 @@ +import { thing } from './leaf.mjs' +export const fromMiddle = () => thing() diff --git a/test/esm-fixtures/import-graph-stub-first/subject.mjs b/test/esm-fixtures/import-graph-stub-first/subject.mjs new file mode 100644 index 0000000..3c3b223 --- /dev/null +++ b/test/esm-fixtures/import-graph-stub-first/subject.mjs @@ -0,0 +1,2 @@ +import { fromMiddle } from './middle.mjs' +export const run = () => fromMiddle() diff --git a/test/esm-fixtures/import-graph/leaf.mjs b/test/esm-fixtures/import-graph/leaf.mjs new file mode 100644 index 0000000..f4dd60b --- /dev/null +++ b/test/esm-fixtures/import-graph/leaf.mjs @@ -0,0 +1 @@ +export const thing = () => 'real' diff --git a/test/esm-fixtures/import-graph/middle.mjs b/test/esm-fixtures/import-graph/middle.mjs new file mode 100644 index 0000000..0b176d1 --- /dev/null +++ b/test/esm-fixtures/import-graph/middle.mjs @@ -0,0 +1,2 @@ +import { thing } from './leaf.mjs' +export const fromMiddle = () => thing() diff --git a/test/esm-fixtures/import-graph/subject.mjs b/test/esm-fixtures/import-graph/subject.mjs new file mode 100644 index 0000000..01fbab7 --- /dev/null +++ b/test/esm-fixtures/import-graph/subject.mjs @@ -0,0 +1,5 @@ +import { fromMiddle } from './middle.mjs' +import { evaluation } from './unrelated.mjs' + +export const run = () => fromMiddle() +export const unrelatedEvaluation = evaluation diff --git a/test/esm-fixtures/import-graph/unrelated.mjs b/test/esm-fixtures/import-graph/unrelated.mjs new file mode 100644 index 0000000..80c6407 --- /dev/null +++ b/test/esm-fixtures/import-graph/unrelated.mjs @@ -0,0 +1,3 @@ +// Counts how many times Node has evaluated this module in this process +globalThis.__quibbleImportGraphEvaluations = (globalThis.__quibbleImportGraphEvaluations ?? 0) + 1 +export const evaluation = globalThis.__quibbleImportGraphEvaluations diff --git a/test/esm-lib/quibble-esm-import-graph.test.mjs b/test/esm-lib/quibble-esm-import-graph.test.mjs new file mode 100644 index 0000000..0c1ef82 --- /dev/null +++ b/test/esm-lib/quibble-esm-import-graph.test.mjs @@ -0,0 +1,73 @@ +import quibble from 'quibble' + +const importSubject = () => import('../esm-fixtures/import-graph/subject.mjs') + +export default { + afterEach: function () { quibble.reset() }, + 'a module that depends on a mock only through another module sees the mock': async function () { + await quibble.esm('../esm-fixtures/import-graph/leaf.mjs', { thing: () => 'fake' }) + + const subject = await importSubject() + + assert.equal(subject.run(), 'fake') + }, + 'each new mock is picked up by the modules that depend on it': async function () { + for (const name of ['first', 'second', 'third', 'fourth', 'fifth']) { + await quibble.esm('../esm-fixtures/import-graph/leaf.mjs', { thing: () => name }) + + assert.equal((await importSubject()).run(), name) + + quibble.reset() + } + }, + 'the real module comes back after a reset': async function () { + await quibble.esm('../esm-fixtures/import-graph/leaf.mjs', { thing: () => 'fake' }) + await importSubject() + quibble.reset() + + assert.equal((await importSubject()).run(), 'real') + }, + 'a module in the middle of a graph can be mocked': async function () { + await quibble.esm('../esm-fixtures/import-graph/middle.mjs', { fromMiddle: () => 'middle fake' }) + + assert.equal((await importSubject()).run(), 'middle fake') + }, + 'modules unrelated to any mock stop being re-evaluated for every new mock': async function () { + const evaluations = [] + for (let i = 0; i < 8; i++) { + await quibble.esm('../esm-fixtures/import-graph/leaf.mjs', { thing: () => `fake ${i}` }) + evaluations.push((await importSubject()).unrelatedEvaluation) + quibble.reset() + } + + // Each unrelated module may be evaluated once under the first (tagged) + // URL it is loaded with and once more at its plain URL, but no more + assert.ok(new Set(evaluations).size <= 2, `evaluated ${new Set(evaluations).size} times: ${evaluations}`) + assert.equal(new Set(evaluations.slice(-3)).size, 1, `still changing: ${evaluations}`) + }, + // Separate fixtures again, so what each module has been used for before this + // test is the same no matter what order the tests run in + 'a module that was only ever loaded as a mock stub does not hide later mocks of its dependencies': async function () { + const subject = () => import('../esm-fixtures/import-graph-stub-first/subject.mjs') + + await quibble.esm('../esm-fixtures/import-graph-stub-first/middle.mjs', { fromMiddle: () => 'middle fake' }) + assert.equal((await subject()).run(), 'middle fake') + quibble.reset() + + // The real middle module is loaded for the first time here, against a fake leaf + await quibble.esm('../esm-fixtures/import-graph-stub-first/leaf.mjs', { thing: () => 'leaf fake' }) + assert.equal((await subject()).run(), 'leaf fake') + quibble.reset() + + assert.equal((await subject()).run(), 'real') + }, + 'a module that does not exist can be mocked again by each new generation': async function () { + for (const name of ['first', 'second', 'third', 'fourth']) { + await quibble.esm('../esm-fixtures/import-graph-missing-module/does-not-exist.mjs', { ghost: () => name }) + + assert.equal((await import('../esm-fixtures/import-graph-missing-module/importer.mjs')).run(), name) + + quibble.reset() + } + } +} From 5e456cfcd08686bfdbc1a69d650c2f8607e30aed Mon Sep 17 00:00:00 2001 From: Ross Brandes Date: Thu, 1 Oct 2026 23:50:06 -0400 Subject: [PATCH 2/3] Test that modules imported before any mock still see later mocks The import graph has to record edges even while nothing is mocked: a module loaded then is treated as fully known by the time a mock exists, so skipping its edges would make it look like it depends on nothing and it would never see a mock added later. Nothing covered that, so "fixing" the `!state.quibbledModules` check in planResolve to `.size` (which skips edge recording while nothing is mocked) passed the whole suite while silently breaking this case. Add a test with fixtures that only it uses, so the subject really is first loaded before any mock regardless of test order, and say why in the import-graph header. --- test/esm-fixtures/import-graph-late-mock/leaf.mjs | 1 + test/esm-fixtures/import-graph-late-mock/middle.mjs | 2 ++ test/esm-fixtures/import-graph-late-mock/subject.mjs | 2 ++ test/esm-lib/quibble-esm-import-graph.test.mjs | 10 ++++++++++ 4 files changed, 15 insertions(+) create mode 100644 test/esm-fixtures/import-graph-late-mock/leaf.mjs create mode 100644 test/esm-fixtures/import-graph-late-mock/middle.mjs create mode 100644 test/esm-fixtures/import-graph-late-mock/subject.mjs diff --git a/test/esm-fixtures/import-graph-late-mock/leaf.mjs b/test/esm-fixtures/import-graph-late-mock/leaf.mjs new file mode 100644 index 0000000..f4dd60b --- /dev/null +++ b/test/esm-fixtures/import-graph-late-mock/leaf.mjs @@ -0,0 +1 @@ +export const thing = () => 'real' diff --git a/test/esm-fixtures/import-graph-late-mock/middle.mjs b/test/esm-fixtures/import-graph-late-mock/middle.mjs new file mode 100644 index 0000000..0b176d1 --- /dev/null +++ b/test/esm-fixtures/import-graph-late-mock/middle.mjs @@ -0,0 +1,2 @@ +import { thing } from './leaf.mjs' +export const fromMiddle = () => thing() diff --git a/test/esm-fixtures/import-graph-late-mock/subject.mjs b/test/esm-fixtures/import-graph-late-mock/subject.mjs new file mode 100644 index 0000000..3c3b223 --- /dev/null +++ b/test/esm-fixtures/import-graph-late-mock/subject.mjs @@ -0,0 +1,2 @@ +import { fromMiddle } from './middle.mjs' +export const run = () => fromMiddle() diff --git a/test/esm-lib/quibble-esm-import-graph.test.mjs b/test/esm-lib/quibble-esm-import-graph.test.mjs index 0c1ef82..e0a2522 100644 --- a/test/esm-lib/quibble-esm-import-graph.test.mjs +++ b/test/esm-lib/quibble-esm-import-graph.test.mjs @@ -45,6 +45,16 @@ export default { assert.ok(new Set(evaluations).size <= 2, `evaluated ${new Set(evaluations).size} times: ${evaluations}`) assert.equal(new Set(evaluations.slice(-3)).size, 1, `still changing: ${evaluations}`) }, + // These fixtures are only used here, so the subject really is first loaded + // before any mock exists no matter what order the tests run in + 'a module imported before any mock exists still sees a mock added later': async function () { + const subject = () => import('../esm-fixtures/import-graph-late-mock/subject.mjs') + assert.equal((await subject()).run(), 'real') + + await quibble.esm('../esm-fixtures/import-graph-late-mock/leaf.mjs', { thing: () => 'fake' }) + + assert.equal((await subject()).run(), 'fake') + }, // Separate fixtures again, so what each module has been used for before this // test is the same no matter what order the tests run in 'a module that was only ever loaded as a mock stub does not hide later mocks of its dependencies': async function () { From 506432cd4378d44e4098ea066882cbceb3f6c1a0 Mon Sep 17 00:00:00 2001 From: Ross Brandes Date: Thu, 1 Oct 2026 23:50:13 -0400 Subject: [PATCH 3/3] Remove checks that can never be true `quibbleLoaderState.quibbledModules` is always a Map (reset() replaces it with a new Map rather than clearing it), so `!state.quibbledModules` is never true and the branch never short-circuited anything. Whether anything is mocked is decided later, once the import graph has been recorded. --- lib/loader-helpers.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/loader-helpers.js b/lib/loader-helpers.js index 1a7adfc..c7b70a2 100644 --- a/lib/loader-helpers.js +++ b/lib/loader-helpers.js @@ -24,7 +24,6 @@ * @returns {[string, ModuleLoaderMockInfo] | undefined} */ function getStubsInfo (state, moduleUrl) { - if (!state.quibbledModules) return undefined if (!moduleUrl.includes('__quibble=')) return undefined const moduleKey = stripQueryAndHash(moduleUrl) @@ -103,7 +102,7 @@ function planResolve (state, specifier, context) { return { kind: 'resolveUrl', nextSpecifier: stripMarkers(specifier) } } - if (!state.quibbledModules || specifier === 'quibble' || specifier.includes('__quibbleoriginal')) { + if (specifier === 'quibble' || specifier.includes('__quibbleoriginal')) { return { kind: 'passthrough', nextSpecifier: stripMarkers(specifier) } }