Skip to content

Commit 8c4af18

Browse files
committed
perf: back ResolveContext.stack with a lazy linked-list Set
Applies the performance improvement from #443 without breaking the public API. `resolveContext.stack` remains a `Set<string>` so consumers like webpack's ResolverCachePlugin continue to work unchanged, but it is now backed by a singly-linked list of structured stack entries: - Extending the stack on each `doResolve` call is O(1) instead of O(n) for cloning a `Set` (the dominant cost identified in #443). - Formatted entry strings are computed lazily, only when the set is iterated, queried with `has`, or its `size` is read. - The recursion check uses structural equality on the raw entry objects, so the hot path allocates no strings at all. The `Set<string>` surface (`has`, `size`, iteration, `keys`, `values`, `entries`, `forEach`, `instanceof Set`) is preserved by having `StackSet` extend `Set` and override its read operations; the generated `types.d.ts` is unchanged.
1 parent 0bb2e70 commit 8c4af18

1 file changed

Lines changed: 213 additions & 25 deletions

File tree

lib/Resolver.js

Lines changed: 213 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,19 @@ const _pathCacheByFs = new WeakMap();
370370
* @typedef {string} StackEntry
371371
*/
372372

373+
/**
374+
* Structured representation of a stack entry used internally for
375+
* structural equality checks without allocating a formatted string.
376+
* @typedef {object} RawStackEntry
377+
* @property {string | undefined} name hook name
378+
* @property {string | false} path request path
379+
* @property {string} request request string
380+
* @property {string} query query string
381+
* @property {string} fragment fragment string
382+
* @property {boolean} directory directory flag
383+
* @property {boolean} module module flag
384+
*/
385+
373386
/**
374387
* @template T
375388
* @typedef {{ add: (item: T) => void }} WriteOnlySet
@@ -410,6 +423,166 @@ function toCamelCase(str) {
410423
return str.replace(/-([a-z])/g, (str) => str.slice(1).toUpperCase());
411424
}
412425

426+
/**
427+
* Formats a raw stack entry to its string representation.
428+
* @param {RawStackEntry} entry raw entry
429+
* @returns {string} formatted stack entry string
430+
*/
431+
function formatStackEntry(entry) {
432+
return `${entry.name}: (${entry.path}) ${entry.request}${entry.query}${
433+
entry.fragment
434+
}${entry.directory ? " directory" : ""}${entry.module ? " module" : ""}`;
435+
}
436+
437+
/**
438+
* Structural equality check between two raw stack entries.
439+
* Avoids formatting to a string on the hot recursion-check path.
440+
* @param {RawStackEntry} a first entry
441+
* @param {RawStackEntry} b second entry
442+
* @returns {boolean} whether the entries are structurally equal
443+
*/
444+
function rawStackEntriesEqual(a, b) {
445+
return (
446+
a.name === b.name &&
447+
a.path === b.path &&
448+
a.request === b.request &&
449+
a.query === b.query &&
450+
a.fragment === b.fragment &&
451+
a.directory === b.directory &&
452+
a.module === b.module
453+
);
454+
}
455+
456+
/**
457+
* A Set<string> that is backed by a singly-linked list of structured
458+
* stack entries. Extending the stack with a new entry is O(1) in time
459+
* and memory (instead of O(n) for cloning a `Set`), and formatted
460+
* entry strings are computed lazily — only when the set is iterated,
461+
* queried with `has`, or its `size` is read.
462+
*
463+
* The public `Set<string>` contract is preserved for backwards
464+
* compatibility so that consumers like webpack's `ResolverCachePlugin`
465+
* can continue to read `resolveContext.stack` as a `Set<string>`.
466+
* @extends {Set<string>}
467+
*/
468+
class StackSet extends Set {
469+
/**
470+
* @param {Set<string> | undefined} parent previous stack (or undefined)
471+
* @param {RawStackEntry} entry raw entry at the tip of this stack
472+
*/
473+
constructor(parent, entry) {
474+
super();
475+
/** @type {Set<string> | undefined} */
476+
this._parent = parent;
477+
/** @type {RawStackEntry} */
478+
this._entry = entry;
479+
/** @type {string | undefined} */
480+
this._string = undefined;
481+
}
482+
483+
/**
484+
* @returns {string} the formatted string for this tip entry (memoized)
485+
*/
486+
_getString() {
487+
if (this._string === undefined) {
488+
this._string = formatStackEntry(this._entry);
489+
}
490+
return this._string;
491+
}
492+
493+
/**
494+
* Fast structural recursion check without allocating any strings.
495+
* @param {RawStackEntry} query raw entry to look for
496+
* @returns {boolean} whether the entry exists in this stack
497+
*/
498+
_hasRaw(query) {
499+
if (rawStackEntriesEqual(this._entry, query)) return true;
500+
const parent = this._parent;
501+
if (!parent) return false;
502+
if (parent instanceof StackSet) return parent._hasRaw(query);
503+
// Fallback for user-provided plain `Set<string>`: pay the string
504+
// formatting cost once to query it.
505+
return parent.has(formatStackEntry(query));
506+
}
507+
508+
/**
509+
* @param {string} value value to check
510+
* @returns {boolean} whether the stack contains the value
511+
*/
512+
has(value) {
513+
if (this._getString() === value) return true;
514+
return this._parent ? this._parent.has(value) : false;
515+
}
516+
517+
/**
518+
* @returns {number} number of entries in the stack
519+
*/
520+
get size() {
521+
return 1 + (this._parent ? this._parent.size : 0);
522+
}
523+
524+
/**
525+
* Walks from the oldest (bottom of stack) to the newest (tip).
526+
* @returns {SetIterator<string>} iterator of formatted stack entries
527+
*/
528+
[Symbol.iterator]() {
529+
return /** @type {SetIterator<string>} */ (
530+
// eslint-disable-next-line no-use-before-define
531+
/** @type {unknown} */ (iterateStackSet(this))
532+
);
533+
}
534+
535+
/**
536+
* @returns {SetIterator<string>} same as [Symbol.iterator]
537+
*/
538+
keys() {
539+
return this[Symbol.iterator]();
540+
}
541+
542+
/**
543+
* @returns {SetIterator<string>} same as [Symbol.iterator]
544+
*/
545+
values() {
546+
return this[Symbol.iterator]();
547+
}
548+
549+
/**
550+
* @returns {SetIterator<[string, string]>} iterator yielding [v, v]
551+
*/
552+
entries() {
553+
return /** @type {SetIterator<[string, string]>} */ (
554+
// eslint-disable-next-line no-use-before-define
555+
/** @type {unknown} */ (iterateStackSetEntries(this))
556+
);
557+
}
558+
559+
/**
560+
* @param {(value: string, value2: string, set: Set<string>) => void} callback callback
561+
* @param {unknown=} thisArg this argument
562+
* @returns {void}
563+
*/
564+
forEach(callback, thisArg) {
565+
for (const v of this) callback.call(thisArg, v, v, this);
566+
}
567+
}
568+
569+
/**
570+
* @param {StackSet} set the set to iterate
571+
* @returns {Generator<string>} generator yielding formatted entries
572+
*/
573+
function* iterateStackSet(set) {
574+
if (set._parent) yield* set._parent;
575+
yield set._getString();
576+
}
577+
578+
/**
579+
* @param {StackSet} set the set to iterate
580+
* @returns {Generator<[string, string]>} generator yielding [v, v] tuples
581+
*/
582+
function* iterateStackSetEntries(set) {
583+
for (const v of set) yield [v, v];
584+
}
585+
413586
class Resolver {
414587
/**
415588
* @param {ResolveStepHook} hook hook
@@ -739,33 +912,48 @@ class Resolver {
739912
* @returns {void}
740913
*/
741914
doResolve(hook, request, message, resolveContext, callback) {
742-
const stackEntry = Resolver.createStackEntry(hook, request);
915+
const parent = resolveContext.stack;
916+
917+
/** @type {RawStackEntry} */
918+
const rawEntry = {
919+
name: hook.name,
920+
path: request.path,
921+
request: request.request || "",
922+
query: request.query || "",
923+
fragment: request.fragment || "",
924+
directory: Boolean(request.directory),
925+
module: Boolean(request.module),
926+
};
743927

744-
/** @type {Set<string> | undefined} */
745-
let newStack;
746-
if (resolveContext.stack) {
747-
newStack = new Set(resolveContext.stack);
748-
if (resolveContext.stack.has(stackEntry)) {
749-
/**
750-
* Prevent recursion
751-
* @type {Error & { recursion?: boolean }}
752-
*/
753-
const recursionError = new Error(
754-
`Recursion in resolving\nStack:\n ${[...newStack].join("\n ")}`,
755-
);
756-
recursionError.recursion = true;
757-
if (resolveContext.log) {
758-
resolveContext.log("abort resolving because of recursion");
759-
}
760-
return callback(recursionError);
928+
// Fast O(n) structural recursion check without allocating strings.
929+
// If the caller supplied a plain `Set<string>` rather than a
930+
// `StackSet`, fall back to the string-based `has` check.
931+
let recursion = false;
932+
if (parent !== undefined) {
933+
recursion =
934+
parent instanceof StackSet
935+
? parent._hasRaw(rawEntry)
936+
: parent.has(formatStackEntry(rawEntry));
937+
}
938+
939+
// O(1) extension of the stack via a linked-list node that lazily
940+
// formats its string representation. Preserves the `Set<string>`
941+
// public contract.
942+
const newStack = new StackSet(parent, rawEntry);
943+
944+
if (recursion) {
945+
/**
946+
* Prevent recursion
947+
* @type {Error & { recursion?: boolean }}
948+
*/
949+
const recursionError = new Error(
950+
`Recursion in resolving\nStack:\n ${[...newStack].join("\n ")}`,
951+
);
952+
recursionError.recursion = true;
953+
if (resolveContext.log) {
954+
resolveContext.log("abort resolving because of recursion");
761955
}
762-
newStack.add(stackEntry);
763-
} else {
764-
// creating a set with new Set([item])
765-
// allocates a new array that has to be garbage collected
766-
// this is an EXTREMELY hot path, so let's avoid it
767-
newStack = new Set();
768-
newStack.add(stackEntry);
956+
return callback(recursionError);
769957
}
770958
this.hooks.resolveStep.call(hook, request);
771959

0 commit comments

Comments
 (0)