diff --git a/package.json b/package.json index ec0be99..104ec31 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "prepublish": "pnpm build", "release": "pnpm test && pnpm build && changelogen --release --push && pnpm publish", "test": "pnpm lint && vitest run --coverage", - "test:types": "tsc --noEmit" + "test:types": "tsc --noEmit", + "benchmark": "vitest bench" }, "devDependencies": { "@types/node": "^18.16.1", diff --git a/src/hookable.ts b/src/hookable.ts index 6aea45a..73a3238 100644 --- a/src/hookable.ts +++ b/src/hookable.ts @@ -9,6 +9,7 @@ import type { NestedHooks, HookCallback, HookKeys, + Thenable, } from "./types"; type InferCallback = HT[HN] extends HookCallback @@ -121,15 +122,17 @@ export class Hookable< name: NameT, function_: InferCallback ) { - if (this._hooks[name]) { - const index = this._hooks[name].indexOf(function_); + const hooks = this._hooks[name]; + + if (hooks) { + const index = hooks.indexOf(function_); if (index !== -1) { - this._hooks[name].splice(index, 1); + hooks.splice(index, 1); } - if (this._hooks[name].length === 0) { - delete this._hooks[name]; + if (hooks.length === 0) { + this._hooks[name] = undefined; } } } @@ -141,7 +144,7 @@ export class Hookable< this._deprecatedHooks[name] = typeof deprecated === "string" ? { to: deprecated } : deprecated; const _hooks = this._hooks[name] || []; - delete this._hooks[name]; + this._hooks[name] = undefined; for (const hook of _hooks) { this.hook(name, hook as any); } @@ -150,7 +153,6 @@ export class Hookable< deprecateHooks( deprecatedHooks: Partial>> ) { - Object.assign(this._deprecatedHooks, deprecatedHooks); for (const name in deprecatedHooks) { this.deprecateHook(name, deprecatedHooks[name] as DeprecatedHook); } @@ -164,11 +166,13 @@ export class Hookable< ); return () => { - // Splice will ensure that all fns are called once, and free all - // unreg functions from memory. - for (const unreg of removeFns.splice(0, removeFns.length)) { + for (const unreg of removeFns) { unreg(); } + + // Ensures that all fns are called once, and free all + // unreg functions from memory. + removeFns.length = 0; }; } @@ -181,25 +185,23 @@ export class Hookable< } removeAllHooks() { - for (const key in this._hooks) { - delete this._hooks[key]; - } + this._hooks = {}; } callHook( name: NameT, ...arguments_: Parameters> - ): Promise { - arguments_.unshift(name); - return this.callHookWith(serialTaskCaller, name, ...arguments_); + ): Thenable { + // @ts-expect-error we always inject name + return this.callHookWith(serialTaskCaller, name, name, ...arguments_); } callHookParallel( name: NameT, ...arguments_: Parameters> - ): Promise { - arguments_.unshift(name); - return this.callHookWith(parallelTaskCaller, name, ...arguments_); + ): Thenable { + // @ts-expect-error we always inject name + return this.callHookWith(parallelTaskCaller, name, name, ...arguments_); } callHookWith< @@ -221,7 +223,7 @@ export class Hookable< callEachWith(this._before, event); } const result = caller( - name in this._hooks ? [...this._hooks[name]] : [], + this._hooks[name] ? [...this._hooks[name]] : [], arguments_ ); if ((result as any) instanceof Promise) { diff --git a/src/types.ts b/src/types.ts index c59f300..560d0a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ export type DeprecatedHook = { message?: string; to: HookKeys }; // eslint-disable-next-line no-unused-vars export type DeprecatedHooks = { [name in HookKeys]: DeprecatedHook }; +export type Thenable = Promise | T; + // Utilities type ValueOf = C extends Record ? C[keyof C] : never; type Strings = Exclude; diff --git a/src/utils.ts b/src/utils.ts index 3ec190c..49b4ffc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -64,20 +64,41 @@ const _createTask: CreateTask = () => defaultTask; const createTask = typeof console.createTask !== "undefined" ? console.createTask : _createTask; +function nextDispatch( + hooks: HookCallback[], + args: any[], + task: typeof defaultTask, + startIndex: number +) { + for (let i = startIndex; i < hooks.length; i += 1) { + try { + const result = task.run(() => hooks[i](...args)); + + if (result instanceof Promise) { + return result.then(() => nextDispatch(hooks, args, task, i + 1)); + } + } catch (error) { + return Promise.reject(error); + } + } +} + export function serialTaskCaller(hooks: HookCallback[], args: any[]) { - const name = args.shift(); - const task = createTask(name); - // eslint-disable-next-line unicorn/no-array-reduce - return hooks.reduce( - (promise, hookFunction) => - promise.then(() => task.run(() => hookFunction(...args))), - Promise.resolve() - ); + if (hooks.length === 0) { + return; + } + + const task = createTask(args.shift()); + + return nextDispatch(hooks, args, task, 0); } export function parallelTaskCaller(hooks: HookCallback[], args: any[]) { - const name = args.shift(); - const task = createTask(name); + if (hooks.length === 0) { + return; + } + + const task = createTask(args.shift()); return Promise.all(hooks.map((hook) => task.run(() => hook(...args)))); } diff --git a/test/hookable.bench.ts b/test/hookable.bench.ts new file mode 100644 index 0000000..0538236 --- /dev/null +++ b/test/hookable.bench.ts @@ -0,0 +1,309 @@ +import { bench, describe } from "vitest"; +import { createHooks } from "../src/index"; +import { serialTaskCaller, parallelTaskCaller, flatHooks } from "../src/utils"; + +describe("empty serialTaskCaller", () => { + const emptyTasks = []; + + bench("empty serialTaskCaller", () => { + return serialTaskCaller(emptyTasks, []); + }); + + bench("empty serialTaskCaller with argument", () => { + return serialTaskCaller(emptyTasks, [1]); + }); + + bench("empty serialTaskCaller with arguments", () => { + return serialTaskCaller(emptyTasks, [1, 2, 3, 4, 5]); + }); +}); + +describe("serialTaskCaller", () => { + const mixedTasks: (() => Promise | void)[] = []; + + for (let i = 0; i < 10; i += 1) { + mixedTasks.push(i % 2 === 2 ? () => Promise.resolve() : () => {}); + } + + bench("serialTaskCaller", () => { + return serialTaskCaller(mixedTasks, []); + }); + + bench("serialTaskCaller with argument", () => { + return serialTaskCaller(mixedTasks, [1]); + }); + + bench("serialTaskCaller with arguments", () => { + return serialTaskCaller(mixedTasks, [1, 2, 3, 4, 5]); + }); +}); + +describe("empty parallelTaskCaller", () => { + const emptyTasks = []; + + bench("empty parallelTaskCaller", () => { + return parallelTaskCaller(emptyTasks, []) as unknown as Promise; + }); + + bench("empty parallelTaskCaller with argument", () => { + return parallelTaskCaller(emptyTasks, [1]) as unknown as Promise; + }); + + bench("empty parallelTaskCaller with arguments", () => { + return parallelTaskCaller( + emptyTasks, + [1, 2, 3, 4, 5] + ) as unknown as Promise; + }); +}); + +describe("parallelTaskCaller", () => { + const mixedTasks: (() => Promise | void)[] = []; + + for (let i = 0; i < 10; i += 1) { + mixedTasks.push(i % 2 === 2 ? () => Promise.resolve() : () => {}); + } + + bench("parallelTaskCaller", () => { + return parallelTaskCaller(mixedTasks, []) as unknown as Promise; + }); + + bench("parallelTaskCaller with argument", () => { + return parallelTaskCaller(mixedTasks, [1]) as unknown as Promise; + }); + + bench("parallelTaskCaller with arguments", () => { + return parallelTaskCaller( + mixedTasks, + [1, 2, 3, 4, 5] + ) as unknown as Promise; + }); +}); + +describe("empty callHook", () => { + const hooks = createHooks(); + + bench("empty callHook", () => { + return hooks.callHook("hello"); + }); + + bench("empty callHook with argument", () => { + return hooks.callHook("hello", 1); + }); + + bench("empty callHook with five arguments", () => { + return hooks.callHook("hello", 1, 2, 3, 4, 5); + }); +}); + +describe("empty callHookParallel", () => { + const hooks = createHooks(); + + bench("empty callHookParallel", () => { + return hooks.callHookParallel("hello") as unknown as Promise; + }); + + bench("empty callHookParallel with argument", () => { + return hooks.callHookParallel("hello", 1) as unknown as Promise; + }); + + bench("empty callHookParallel with five arguments", () => { + return hooks.callHookParallel( + "hello", + 1, + 2, + 3, + 4, + 5 + ) as unknown as Promise; + }); +}); + +describe("callHook", () => { + const hooks = createHooks(); + + for (let i = 0; i < 10; i += 1) { + hooks.hook("hello", i % 2 === 2 ? () => Promise.resolve() : () => {}); + } + + bench("callHook", () => { + return hooks.callHook("hello"); + }); + + bench("callHook with argument", () => { + return hooks.callHook("hello", 1); + }); + + bench("callHook with five arguments", () => { + return hooks.callHook("hello", 1, 2, 3, 4, 5); + }); +}); + +describe("callHookParallel", () => { + const hooks = createHooks(); + + for (let i = 0; i < 10; i += 1) { + hooks.hook("hello", i % 2 === 2 ? () => Promise.resolve() : () => {}); + } + + bench("callHookParallel", () => { + return hooks.callHookParallel("hello") as unknown as Promise; + }); + + bench("callHookParallel with argument", () => { + return hooks.callHookParallel("hello", 1) as unknown as Promise; + }); + + bench("callHookParallel with five arguments", () => { + return hooks.callHookParallel( + "hello", + 1, + 2, + 3, + 4, + 5 + ) as unknown as Promise; + }); +}); + +describe("hook", () => { + let hooks = createHooks(); + + bench( + "hook", + () => { + hooks.hook("hello", () => {}); + }, + { + setup: () => { + hooks = createHooks(); + }, + } + ); + + const createDeprecateHooks = () => { + const instance = createHooks(); + + instance.deprecateHook("hello", "This hook is deprecated"); + + return instance; + }; + + let deprecatedHooks = createDeprecateHooks(); + + bench( + "hook with deprecate", + () => { + hooks.hook("hello", () => {}); + }, + { + setup: () => { + deprecatedHooks = createDeprecateHooks(); + }, + } + ); +}); + +describe("addHooks", () => { + let hooks = createHooks(); + + // eslint-disable-next-line unicorn/consistent-function-scoping + const fn = () => {}; + + bench( + "addHooks", + () => { + hooks.addHooks({ + hello: fn, + hello1: fn, + helloNested: { + hello2: fn, + hello3: fn, + }, + }); + }, + { + setup: () => { + hooks = createHooks(); + }, + } + ); +}); + +describe("empty removeHook", () => { + const hooks = createHooks(); + + // eslint-disable-next-line unicorn/consistent-function-scoping + const fn = () => {}; + + bench("empty removeHook", () => { + return hooks.removeHook("hello", fn); + }); +}); + +describe("removeHook", () => { + const hooks = createHooks(); + + // eslint-disable-next-line unicorn/new-for-builtins + const fns = Array(10).fill(() => {}); + + let i = 0; + + bench( + "removeHook", + () => { + i += 1; + + return hooks.removeHook("hello", fns[i % fns.length]); + }, + { + setup: () => { + hooks.removeAllHooks(); + for (const fn of fns) { + hooks.hook("hello", fn); + } + }, + } + ); + + // eslint-disable-next-line unicorn/consistent-function-scoping + const extraOneFn = () => {}; + // eslint-disable-next-line unicorn/consistent-function-scoping + const extraTwoFn = () => {}; + + bench( + "removeHook with extra", + () => { + i += 1; + + return hooks.removeHook("hello", fns[i % fns.length]); + }, + { + setup: () => { + hooks.removeAllHooks(); + + hooks.addHooks(extraOneFn); + for (const fn of fns) { + hooks.hook("hello", fn); + } + hooks.addHooks(extraTwoFn); + }, + } + ); +}); + +describe("flatHooks", () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + const fn = () => {}; + + bench("flatHooks", () => { + return flatHooks({ + hello: fn, + hello1: fn, + helloNested: { + hello2: fn, + hello3: fn, + }, + }) as unknown as void; + }); +});