Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 67 additions & 24 deletions lib/Hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,45 @@ class Hook {

_tap(type, options, fn) {
if (typeof options === "string") {
options = {
name: options
};
} else if (typeof options !== "object" || options === null) {
throw new Error("Invalid tap options");
}
if (typeof options.name === "string") {
options.name = options.name.trim();
}
if (typeof options.name !== "string" || options.name === "") {
throw new Error("Missing name for tap");
}
if (typeof options.context !== "undefined") {
deprecateContext();
// Fast path: a string options ("name") is by far the most common
// case. Build the final descriptor in a single allocation instead
// of creating `{ name }` and then `Object.assign`ing it.
const name = options.trim();
if (name === "") {
throw new Error("Missing name for tap");
}
options = { type, fn, name };
} else {
if (typeof options !== "object" || options === null) {
throw new Error("Invalid tap options");
}
let { name } = options;
if (typeof name === "string") {
name = name.trim();
}
if (typeof name !== "string" || name === "") {
throw new Error("Missing name for tap");
}
if (typeof options.context !== "undefined") {
deprecateContext();
}
// Fast path: only `name` is set. Build the descriptor as a literal
// so `_insert` and downstream consumers see the same hidden class
// as the string-options path, avoiding a polymorphic call site.
if (
options.before === undefined &&
options.stage === undefined &&
options.context === undefined &&
options.type === undefined &&
options.fn === undefined
) {
options = { type, fn, name };
} else {
options.name = name;
// Preserve previous precedence: user-provided keys win over the internal `type`/`fn`.
options = Object.assign({ type, fn }, options);
}
}
options = Object.assign({ type, fn }, options);
options = this._runRegisterInterceptors(options);
this._insert(options);
}
Expand All @@ -98,7 +121,12 @@ class Hook {
}

_runRegisterInterceptors(options) {
for (const interceptor of this.interceptors) {
const { interceptors } = this;
const { length } = interceptors;
// Common case: no interceptors.
if (length === 0) return options;
for (let i = 0; i < length; i++) {
const interceptor = interceptors[i];
if (interceptor.register) {
const newOptions = interceptor.register(options);
if (newOptions !== undefined) {
Expand Down Expand Up @@ -146,21 +174,36 @@ class Hook {

_insert(item) {
this._resetCompilation();
const { taps } = this;
const stage = typeof item.stage === "number" ? item.stage : 0;

// Fast path: the overwhelmingly common `hook.tap("name", fn)` case
// has no `before` and default stage 0. If the list is empty or the
// last tap's stage is <= the new item's stage the item belongs at
// the end - append in O(1), skipping the Set allocation and the
// shift loop.
if (!(typeof item.before === "string" || Array.isArray(item.before))) {
const n = taps.length;
if (n === 0 || (taps[n - 1].stage || 0) <= stage) {
taps[n] = item;
return;
}
}

let before;

if (typeof item.before === "string") {
before = new Set([item.before]);
} else if (Array.isArray(item.before)) {
before = new Set(item.before);
}
let stage = 0;
if (typeof item.stage === "number") {
stage = item.stage;
}
let i = this.taps.length;

let i = taps.length;

while (i > 0) {
i--;
const tap = this.taps[i];
this.taps[i + 1] = tap;
const tap = taps[i];
taps[i + 1] = tap;
const xStage = tap.stage || 0;
if (before) {
if (before.has(tap.name)) {
Expand All @@ -177,7 +220,7 @@ class Hook {
i++;
break;
}
this.taps[i] = item;
taps[i] = item;
}
}

Expand Down
83 changes: 59 additions & 24 deletions lib/HookCodeFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,30 @@ class HookCodeFactory {
}

setup(instance, options) {
instance._x = options.taps.map((t) => t.fn);
const { taps } = options;
const { length } = taps;
const fns = Array.from({ length });
for (let i = 0; i < length; i++) {
fns[i] = taps[i].fn;
}
instance._x = fns;
}

/**
* @param {{ type: "sync" | "promise" | "async", taps: Array<Tap>, interceptors: Array<Interceptor> }} options
*/
init(options) {
this.options = options;
this._args = [...options.args];
// slice() avoids the iterator protocol overhead of [...arr].
// eslint-disable-next-line unicorn/prefer-spread
this._args = options.args.slice();
this._joinedArgs = undefined;
}

deinit() {
this.options = undefined;
this._args = undefined;
this._joinedArgs = undefined;
}

contentWithInterceptors(options) {
Expand Down Expand Up @@ -165,7 +175,10 @@ class HookCodeFactory {
}

needContext() {
for (const tap of this.options.taps) if (tap.context) return true;
const { taps } = this.options;
for (let i = 0; i < taps.length; i++) {
if (taps[i].context) return true;
}
return false;
}

Expand Down Expand Up @@ -274,17 +287,30 @@ class HookCodeFactory {
doneReturns,
rethrowIfPossible
}) {
if (this.options.taps.length === 0) return onDone();
const firstAsync = this.options.taps.findIndex((t) => t.type !== "sync");
const { taps } = this.options;
const tapsLength = taps.length;
if (tapsLength === 0) return onDone();
// Inlined findIndex to avoid the callback allocation.
let firstAsync = -1;
for (let i = 0; i < tapsLength; i++) {
if (taps[i].type !== "sync") {
firstAsync = i;
break;
}
}
const somethingReturns = resultReturns || doneReturns;
// doneBreak doesn't depend on the loop variable - hoist to allocate once.
const doneBreak = (skipDone) => {
if (skipDone) return "";
return onDone();
};
let code = "";
let current = onDone;
let unrollCounter = 0;
for (let j = this.options.taps.length - 1; j >= 0; j--) {
for (let j = tapsLength - 1; j >= 0; j--) {
const i = j;
const unroll =
current !== onDone &&
(this.options.taps[i].type !== "sync" || unrollCounter++ > 20);
current !== onDone && (taps[i].type !== "sync" || unrollCounter++ > 20);
if (unroll) {
unrollCounter = 0;
code += `function _next${i}() {\n`;
Expand All @@ -293,10 +319,6 @@ class HookCodeFactory {
current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`;
}
const done = current;
const doneBreak = (skipDone) => {
if (skipDone) return "";
return onDone();
};
const content = this.callTap(i, {
onError: (error) => onError(i, error, done, doneBreak),
onResult:
Expand Down Expand Up @@ -370,31 +392,35 @@ class HookCodeFactory {
rethrowIfPossible,
onTap = (i, run) => run()
}) {
if (this.options.taps.length <= 1) {
const { taps } = this.options;
const tapsLength = taps.length;
if (tapsLength <= 1) {
return this.callTapsSeries({
onError,
onResult,
onDone,
rethrowIfPossible
});
}
// done and doneBreak don't depend on the loop variable - hoist them
// so they're allocated once per compile instead of once per tap.
const done = () => {
if (onDone) return "if(--_counter === 0) _done();\n";
return "--_counter;";
};
const doneBreak = (skipDone) => {
if (skipDone || !onDone) return "_counter = 0;\n";
return "_counter = 0;\n_done();\n";
};
let code = "";
code += "do {\n";
code += `var _counter = ${this.options.taps.length};\n`;
code += `var _counter = ${tapsLength};\n`;
if (onDone) {
code += "var _done = (function() {\n";
code += onDone();
code += "});\n";
}
for (let i = 0; i < this.options.taps.length; i++) {
const done = () => {
if (onDone) return "if(--_counter === 0) _done();\n";
return "--_counter;";
};
const doneBreak = (skipDone) => {
if (skipDone || !onDone) return "_counter = 0;\n";
return "_counter = 0;\n_done();\n";
};
for (let i = 0; i < tapsLength; i++) {
code += "if(_counter <= 0) break;\n";
code += onTap(
i,
Expand Down Expand Up @@ -428,13 +454,22 @@ class HookCodeFactory {
}

args({ before, after } = {}) {
// Hot during code generation (called once per tap + per interceptor).
// Cache the common no-before/no-after result so we only join once.
if (before === undefined && after === undefined) {
let joined = this._joinedArgs;
if (joined === undefined) {
joined = this._args.length === 0 ? "" : this._args.join(", ");
this._joinedArgs = joined;
}
return joined;
}
let allArgs = this._args;
if (before) allArgs = [before, ...allArgs];
if (after) allArgs = [...allArgs, after];
if (allArgs.length === 0) {
return "";
}

return allArgs.join(", ");
}

Expand Down
8 changes: 6 additions & 2 deletions lib/HookMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ class HookMap {
}

for(key) {
const hook = this.get(key);
// Hot path: inline the map lookup to skip the `this.get(key)`
// indirection. This gets hit on every hook access in consumers
// like webpack.
const map = this._map;
const hook = map.get(key);
if (hook !== undefined) {
return hook;
}
Expand All @@ -30,7 +34,7 @@ class HookMap {
for (let i = 0; i < interceptors.length; i++) {
newHook = interceptors[i].factory(key, newHook);
}
this._map.set(key, newHook);
map.set(key, newHook);
return newHook;
}

Expand Down
25 changes: 15 additions & 10 deletions lib/MultiHook.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,38 @@ class MultiHook {
}

tap(options, fn) {
for (const hook of this.hooks) {
hook.tap(options, fn);
const { hooks } = this;
for (let i = 0; i < hooks.length; i++) {
hooks[i].tap(options, fn);
}
}

tapAsync(options, fn) {
for (const hook of this.hooks) {
hook.tapAsync(options, fn);
const { hooks } = this;
for (let i = 0; i < hooks.length; i++) {
hooks[i].tapAsync(options, fn);
}
}

tapPromise(options, fn) {
for (const hook of this.hooks) {
hook.tapPromise(options, fn);
const { hooks } = this;
for (let i = 0; i < hooks.length; i++) {
hooks[i].tapPromise(options, fn);
}
}

isUsed() {
for (const hook of this.hooks) {
if (hook.isUsed()) return true;
const { hooks } = this;
for (let i = 0; i < hooks.length; i++) {
if (hooks[i].isUsed()) return true;
}
return false;
}

intercept(interceptor) {
for (const hook of this.hooks) {
hook.intercept(interceptor);
const { hooks } = this;
for (let i = 0; i < hooks.length; i++) {
hooks[i].intercept(interceptor);
}
}

Expand Down
Loading