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
30 changes: 1 addition & 29 deletions .changeset/perf-tap-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,4 @@
"tapable": patch
---

Perf: reduce allocations and work on the tap registration and compile paths.

- `Hook#_tap` builds the final tap descriptor in a single allocation for the
common `hook.tap("name", fn)` string-options case instead of creating an
intermediate `{ name }` object that was then merged via `Object.assign`.
- `Hook#_insert` takes an O(1) fast path for the common append case (no
`before`, and stage consistent with the last tap) - the previous
implementation always ran the shift loop once.
- `Hook#_runRegisterInterceptors` early-returns when there are no
interceptors and uses an indexed loop instead of `for…of`.
- `HookMap#for` inlines the `_map.get` lookup instead of routing through
`this.get(key)`, saving a method dispatch on a path hit once per hook
access in consumers like webpack.
- `HookCodeFactory#setup` builds `_x` with a preallocated array + explicit
loop instead of `Array.prototype.map`.
- `HookCodeFactory#init` uses `Array.prototype.slice` instead of spread to
skip the iterator protocol.
- `HookCodeFactory#args` memoizes the common no-before/no-after result so
arguments are joined once per compile rather than once per tap.
- `HookCodeFactory#needContext`, `callTapsSeries`, `callTapsParallel` and
`MultiHook`'s iteration use indexed loops with cached length, and the
series/parallel code hoists the per-tap `done`/`doneBreak` closures
out of the compile-time loop. Replaces `Array.prototype.findIndex`
with a local loop to avoid callback allocation.

Registering 10 taps on a `SyncHook` is roughly 2× faster,
`SyncHook: tap 5 + first call (compile)` is ~15% faster, and
`HookMap#for (existing key)` is ~6% faster in the micro-benchmarks.
The `.call()` path is unchanged.
Improved performance in many places.
4 changes: 2 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ export default defineConfig([
}
},
{
files: ["lib/__tests__/**/*.js"],
files: ["test/**/*"],
languageOptions: {
parserOptions: {
ecmaVersion: 2018
}
}
},
{
files: ["benchmark/**/*.mjs"],
files: ["benchmark/**/*"],
languageOptions: {
parserOptions: {
ecmaVersion: 2022
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
"types": "./tapable.d.ts",
"files": [
"lib",
"!lib/__tests__",
"tapable.d.ts"
],
"scripts": {
Expand All @@ -38,7 +37,7 @@
},
"jest": {
"transform": {
"__tests__[\\\\/].+\\.js$": "babel-jest"
"test[\\\\/].+\\.js$": "babel-jest"
},
"snapshotFormat": {
"escapeString": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
*/
"use strict";

const AsyncParallelBailHook = require("../AsyncParallelBailHook");
const AsyncParallelHook = require("../AsyncParallelHook");
const HookTester = require("./HookTester");
const AsyncParallelBailHook = require("../lib/AsyncParallelBailHook");
const AsyncParallelHook = require("../lib/AsyncParallelHook");
const HookTester = require("./HookTester.test");

describe("AsyncParallelHook", () => {
it("should have to correct behavior", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
*/
"use strict";

const AsyncSeriesBailHook = require("../AsyncSeriesBailHook");
const AsyncSeriesHook = require("../AsyncSeriesHook");
const AsyncSeriesLoopHook = require("../AsyncSeriesLoopHook");
const AsyncSeriesWaterfallHook = require("../AsyncSeriesWaterfallHook");
const HookTester = require("./HookTester");
const AsyncSeriesBailHook = require("../lib/AsyncSeriesBailHook");
const AsyncSeriesHook = require("../lib/AsyncSeriesHook");
const AsyncSeriesLoopHook = require("../lib/AsyncSeriesLoopHook");
const AsyncSeriesWaterfallHook = require("../lib/AsyncSeriesWaterfallHook");
const HookTester = require("./HookTester.test");

describe("AsyncSeriesHook", () => {
it("should not have call method", () => {
Expand Down
20 changes: 10 additions & 10 deletions lib/__tests__/DefaultArgs.js → test/DefaultArgs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
*/
"use strict";

const AsyncParallelBailHook = require("../AsyncParallelBailHook");
const AsyncParallelHook = require("../AsyncParallelHook");
const AsyncSeriesBailHook = require("../AsyncSeriesBailHook");
const AsyncSeriesHook = require("../AsyncSeriesHook");
const AsyncSeriesLoopHook = require("../AsyncSeriesLoopHook");
const AsyncSeriesWaterfallHook = require("../AsyncSeriesWaterfallHook");
const SyncBailHook = require("../SyncBailHook");
const SyncHook = require("../SyncHook");
const SyncLoopHook = require("../SyncLoopHook");
const SyncWaterfallHook = require("../SyncWaterfallHook");
const AsyncParallelBailHook = require("../lib/AsyncParallelBailHook");
const AsyncParallelHook = require("../lib/AsyncParallelHook");
const AsyncSeriesBailHook = require("../lib/AsyncSeriesBailHook");
const AsyncSeriesHook = require("../lib/AsyncSeriesHook");
const AsyncSeriesLoopHook = require("../lib/AsyncSeriesLoopHook");
const AsyncSeriesWaterfallHook = require("../lib/AsyncSeriesWaterfallHook");
const SyncBailHook = require("../lib/SyncBailHook");
const SyncHook = require("../lib/SyncHook");
const SyncLoopHook = require("../lib/SyncLoopHook");
const SyncWaterfallHook = require("../lib/SyncWaterfallHook");

describe("Hooks without explicit args", () => {
it("should construct SyncHook without args", () => {
Expand Down
6 changes: 3 additions & 3 deletions lib/__tests__/Hook.js → test/Hook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
*/
"use strict";

const Hook = require("../Hook");
const SyncHook = require("../SyncHook");
const HookTest = require("../lib/Hook");
const SyncHook = require("../lib/SyncHook");

describe("Hook", () => {
it("should throw when compile is not overridden", () => {
const hook = new Hook(["arg"]);
const hook = new HookTest(["arg"]);
expect(() =>
hook.compile({ taps: [], interceptors: [], args: [], type: "sync" })
).toThrow(/Abstract: should be overridden/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
"use strict";

const HookCodeFactory = require("../HookCodeFactory");
const HookCodeFactoryTest = require("../lib/HookCodeFactory");

const expectNoSyntaxError = (code) => {
// eslint-disable-next-line no-new
Expand Down Expand Up @@ -80,7 +80,7 @@ describe("HookCodeFactory", () => {
let factory;

beforeEach(() => {
factory = new HookCodeFactory();
factory = new HookCodeFactoryTest();
factory.init(factoryConfigurations[configurationName]);
});

Expand Down Expand Up @@ -233,7 +233,7 @@ describe("HookCodeFactory", () => {
let factory;

beforeEach(() => {
factory = new HookCodeFactory();
factory = new HookCodeFactoryTest();
factory.init(factoryConfigurations[configurationName]);
});

Expand Down
22 changes: 11 additions & 11 deletions lib/__tests__/HookMap.js → test/HookMap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
*/
"use strict";

const HookMap = require("../HookMap");
const SyncHook = require("../SyncHook");
const HookMapTest = require("../lib/HookMap");
const SyncHook = require("../lib/SyncHook");

describe("HookMap", () => {
it("should return undefined from get when the key is unknown", () => {
const map = new HookMap(() => new SyncHook());
const map = new HookMapTest(() => new SyncHook());
expect(map.get("missing")).toBeUndefined();
});

it("should lazily create hooks through for(...) and cache them", () => {
const factory = jest.fn(() => new SyncHook(["a"]));
const map = new HookMap(factory, "myMap");
const map = new HookMapTest(factory, "myMap");

expect(map.name).toBe("myMap");

Expand All @@ -32,7 +32,7 @@ describe("HookMap", () => {
});

it("should apply interceptor factories when creating hooks", () => {
const map = new HookMap(() => new SyncHook());
const map = new HookMapTest(() => new SyncHook());
const wrapped = new SyncHook();

map.intercept({
Expand All @@ -47,7 +47,7 @@ describe("HookMap", () => {
});

it("should default the interceptor factory to pass-through", () => {
const map = new HookMap(() => new SyncHook());
const map = new HookMapTest(() => new SyncHook());
map.intercept({});
const hook = map.for("bar");
expect(hook).toBeDefined();
Expand All @@ -56,15 +56,15 @@ describe("HookMap", () => {

it("should forward deprecated tap helpers to the underlying hook", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const map = new HookMap(() => new SyncHook(["a"]));
const map = new HookMapTest(() => new SyncHook(["a"]));

const syncMock = jest.fn();
map.tap("k", "plugin-sync", syncMock);
map.for("k").call(1);
expect(syncMock).toHaveBeenCalledWith(1);

const asyncMap = new HookMap(
() => new (require("../AsyncSeriesHook"))(["a"])
const asyncMap = new HookMapTest(
() => new (require("../lib/AsyncSeriesHook"))(["a"])
);
const asyncMock = jest.fn((_a, cb) => cb());
asyncMap.tapAsync("k", "plugin-async", asyncMock);
Expand All @@ -73,8 +73,8 @@ describe("HookMap", () => {
asyncMap.for("k").callAsync(2, () => {
expect(asyncMock).toHaveBeenCalled();

const promiseMap = new HookMap(
() => new (require("../AsyncSeriesHook"))(["a"])
const promiseMap = new HookMapTest(
() => new (require("../lib/AsyncSeriesHook"))(["a"])
);
const promiseMock = jest.fn(() => Promise.resolve());
promiseMap.tapPromise("k", "plugin-promise", promiseMock);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";

const AsyncSeriesHook = require("../AsyncSeriesHook");
const AsyncSeriesHook = require("../lib/AsyncSeriesHook");

describe("HookStackOverflow", () => {
it("should not crash when compiling a large hook", () => {
Expand Down
4 changes: 2 additions & 2 deletions lib/__tests__/HookTester.js → test/HookTester.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe("HookTester", () => {

process.on("unhandledRejection", (err) => console.error(err.stack));

class HookTester {
class HookTesterTest {
constructor(hookCreator, sync) {
this.hookCreator = hookCreator;
this.sync = sync;
Expand Down Expand Up @@ -1339,4 +1339,4 @@ class HookTester {
}

// eslint-disable-next-line jest/no-export
module.exports = HookTester;
module.exports = HookTesterTest;
18 changes: 10 additions & 8 deletions lib/__tests__/MultiHook.js → test/MultiHook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
"use strict";

const MultiHook = require("../MultiHook");
const MultiHookTest = require("../lib/MultiHook");

describe("MultiHook", () => {
const redirectedMethods = ["tap", "tapAsync", "tapPromise"];
Expand All @@ -16,7 +16,7 @@ describe("MultiHook", () => {
calls.push({ options, fn });
}
};
new MultiHook([fakeHook, fakeHook])[name]("options", "fn");
new MultiHookTest([fakeHook, fakeHook])[name]("options", "fn");
expect(calls).toEqual([
{ options: "options", fn: "fn" },
{ options: "options", fn: "fn" }
Expand All @@ -31,7 +31,7 @@ describe("MultiHook", () => {
calls.push(interceptor);
}
};
new MultiHook([fakeHook, fakeHook]).intercept("interceptor");
new MultiHookTest([fakeHook, fakeHook]).intercept("interceptor");
expect(calls).toEqual(["interceptor", "interceptor"]);
});

Expand All @@ -47,7 +47,9 @@ describe("MultiHook", () => {
};
}
};
const newHook = new MultiHook([fakeHook, fakeHook]).withOptions("options");
const newHook = new MultiHookTest([fakeHook, fakeHook]).withOptions(
"options"
);
newHook.tap("options", "fn");
expect(calls).toEqual([
"options",
Expand All @@ -64,9 +66,9 @@ describe("MultiHook", () => {
const fakeHook2 = {
isUsed: () => false
};
expect(new MultiHook([fakeHook1, fakeHook1]).isUsed()).toBe(true);
expect(new MultiHook([fakeHook1, fakeHook2]).isUsed()).toBe(true);
expect(new MultiHook([fakeHook2, fakeHook1]).isUsed()).toBe(true);
expect(new MultiHook([fakeHook2, fakeHook2]).isUsed()).toBe(false);
expect(new MultiHookTest([fakeHook1, fakeHook1]).isUsed()).toBe(true);
expect(new MultiHookTest([fakeHook1, fakeHook2]).isUsed()).toBe(true);
expect(new MultiHookTest([fakeHook2, fakeHook1]).isUsed()).toBe(true);
expect(new MultiHookTest([fakeHook2, fakeHook2]).isUsed()).toBe(false);
});
});
16 changes: 8 additions & 8 deletions lib/__tests__/SyncBailHook.js → test/SyncBailHook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
"use strict";

const SyncBailHook = require("../SyncBailHook");
const SyncBailHookTest = require("../lib/SyncBailHook");

function pify(fn) {
return new Promise((resolve, reject) => {
Expand All @@ -17,8 +17,8 @@ function pify(fn) {

describe("SyncBailHook", () => {
it("should allow to create sync bail hooks", async () => {
const h1 = new SyncBailHook(["a"]);
const h2 = new SyncBailHook(["a", "b"]);
const h1 = new SyncBailHookTest(["a"]);
const h2 = new SyncBailHookTest(["a", "b"]);

const r = h1.call(1);
expect(r).toBeUndefined();
Expand All @@ -45,7 +45,7 @@ describe("SyncBailHook", () => {
});

it("should bail on non-null return", async () => {
const h1 = new SyncBailHook(["a"]);
const h1 = new SyncBailHookTest(["a"]);
const mockCall1 = jest.fn();
const mockCall2 = jest.fn(() => "B");
const mockCall3 = jest.fn(() => "C");
Expand All @@ -59,7 +59,7 @@ describe("SyncBailHook", () => {
});

it("should allow to intercept calls", () => {
const hook = new SyncBailHook(["x"]);
const hook = new SyncBailHookTest(["x"]);

const mockCall = jest.fn();
const mockTap = jest.fn((x) => x);
Expand All @@ -83,17 +83,17 @@ describe("SyncBailHook", () => {
});

it("should throw on tapAsync", () => {
const hook = new SyncBailHook(["x"]);
const hook = new SyncBailHookTest(["x"]);
expect(() => hook.tapAsync()).toThrow(/tapAsync/);
});

it("should throw on tapPromise", () => {
const hook = new SyncBailHook(["x"]);
const hook = new SyncBailHookTest(["x"]);
expect(() => hook.tapPromise()).toThrow(/tapPromise/);
});

it("should not crash with many plugins", () => {
const hook = new SyncBailHook(["x"]);
const hook = new SyncBailHookTest(["x"]);
for (let i = 0; i < 1000; i++) {
hook.tap("Test", () => 42);
}
Expand Down
Loading
Loading