diff --git a/lib/Resolver.js b/lib/Resolver.js index 97660905..a52c6067 100644 --- a/lib/Resolver.js +++ b/lib/Resolver.js @@ -758,52 +758,60 @@ class Resolver { doResolve(hook, request, message, resolveContext, callback) { const stackEntry = Resolver.createStackEntry(hook, request); - /** @type {Set | undefined} */ - let newStack; - if (resolveContext.stack) { - newStack = new Set(resolveContext.stack); - if (resolveContext.stack.has(stackEntry)) { - /** - * Prevent recursion - * @type {Error & { recursion?: boolean }} - */ - const recursionError = new Error( - `Recursion in resolving\nStack:\n ${[...newStack].join("\n ")}`, - ); - recursionError.recursion = true; - if (resolveContext.log) { - resolveContext.log("abort resolving because of recursion"); - } - return callback(recursionError); + // Recursion guard: each top-level `resolve()` call shares one Set across + // the whole pipeline. We add the current frame on the way down and + // remove it on the way up, so the Set only ever contains the live path + // from the root resolve down to the current hook. Avoids the + // O(depth^2) Set cloning that the previous "snapshot per call" + // implementation incurred on every doResolve. + let { stack } = resolveContext; + if (stack === undefined) { + stack = new Set(); + resolveContext.stack = stack; + } else if (stack.has(stackEntry)) { + /** + * Prevent recursion + * @type {Error & { recursion?: boolean }} + */ + const recursionError = new Error( + `Recursion in resolving\nStack:\n ${[...stack, stackEntry].join("\n ")}`, + ); + recursionError.recursion = true; + if (resolveContext.log) { + resolveContext.log("abort resolving because of recursion"); } - newStack.add(stackEntry); - } else { - // creating a set with new Set([item]) - // allocates a new array that has to be garbage collected - // this is an EXTREMELY hot path, so let's avoid it - newStack = new Set(); - newStack.add(stackEntry); + return callback(recursionError); } + stack.add(stackEntry); this.hooks.resolveStep.call(hook, request); if (hook.isUsed()) { - const innerContext = createInnerContext( - { - log: resolveContext.log, - yield: resolveContext.yield, - fileDependencies: resolveContext.fileDependencies, - contextDependencies: resolveContext.contextDependencies, - missingDependencies: resolveContext.missingDependencies, - stack: newStack, - }, - message, - ); + // Hot path: when there is no log, the inner context is identical to + // the parent context (stack is shared via mutation), so we forward + // it directly and avoid two object allocations + a closure per + // doResolve call. Only the logging path needs createInnerContext to + // install the deferred-header wrapper. + const innerContext = resolveContext.log + ? createInnerContext( + { + log: resolveContext.log, + yield: resolveContext.yield, + fileDependencies: resolveContext.fileDependencies, + contextDependencies: resolveContext.contextDependencies, + missingDependencies: resolveContext.missingDependencies, + stack, + }, + message, + ) + : resolveContext; return hook.callAsync(request, innerContext, (err, result) => { + stack.delete(stackEntry); if (err) return callback(err); if (result) return callback(null, result); callback(); }); } + stack.delete(stackEntry); callback(); }