diff --git a/src/static/js/pluginfw/hooks.js b/src/static/js/pluginfw/hooks.js index 459f84489..72da63021 100644 --- a/src/static/js/pluginfw/hooks.js +++ b/src/static/js/pluginfw/hooks.js @@ -175,6 +175,8 @@ const callHookFnSync = (hook, context) => { return outcome.val; }; +// DEPRECATED: Use `callAllSerial()` or `aCallAll()` instead. +// // Invokes all registered hook functions synchronously. // // Arguments: @@ -317,7 +319,10 @@ const callHookFnAsync = async (hook, context) => { }); }; -// Invokes all registered hook functions asynchronously. +// Invokes all registered hook functions asynchronously and concurrently. This is NOT the async +// equivalent of `callAll()`: `callAll()` calls the hook functions serially (one at a time) but this +// function calls them concurrently. Use `callAllSerial()` if the hook functions must be called one +// at a time. // // Arguments: // * hookName: Name of the hook to invoke. @@ -344,6 +349,19 @@ exports.aCallAll = async (hookName, context, cb = null) => { return flatten1(results); }; +// Like `aCallAll()` except the hook functions are called one at a time instead of concurrently. +// Only use this function if the hook functions must be called one at a time, otherwise use +// `aCallAll()`. +exports.callAllSerial = async (hookName, context) => { + if (context == null) context = {}; + const hooks = pluginDefs.hooks[hookName] || []; + const results = []; + for (const hook of hooks) { + results.push(normalizeValue(await callHookFnAsync(hook, context))); + } + return flatten1(results); +}; + // DEPRECATED: Use `aCallFirst()` instead. // // Like `aCallFirst()`, but synchronous. Hook functions must provide their values synchronously. diff --git a/tests/backend/specs/hooks.js b/tests/backend/specs/hooks.js index 2ea7fac00..865cb132b 100644 --- a/tests/backend/specs/hooks.js +++ b/tests/backend/specs/hooks.js @@ -968,6 +968,89 @@ describe(__filename, function () { }); }); + describe('hooks.callAllSerial', function () { + describe('basic behavior', function () { + it('calls all asynchronously, serially, in order', async function () { + const gotCalls = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = async () => { + gotCalls.push(i); + // Check gotCalls asynchronously to ensure that the next hook function does not start + // executing before this hook function has resolved. + return await new Promise((resolve) => { + setImmediate(() => { + assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); + resolve(i); + }); + }); + }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.callAllSerial(hookName), [0, 1, 2]); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + + it('passes hook name', async function () { + hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; + await hooks.callAllSerial(hookName); + }); + + it('undefined context -> {}', async function () { + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.callAllSerial(hookName); + }); + + it('null context -> {}', async function () { + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.callAllSerial(hookName, null); + }); + + it('context unmodified', async function () { + const wantContext = {}; + hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; + await hooks.callAllSerial(hookName, wantContext); + }); + }); + + describe('result processing', function () { + it('no registered hooks (undefined) -> []', async function () { + delete plugins.hooks[hookName]; + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + + it('no registered hooks (empty list) -> []', async function () { + testHooks.length = 0; + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + + it('flattens one level', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); + assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]); + }); + + it('filters out undefined', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); + assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]); + }); + + it('preserves null', async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); + assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]); + }); + + it('all undefined -> []', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook(Promise.resolve())); + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + }); + }); + describe('hooks.aCallFirst', function () { it('no registered hooks (undefined) -> []', async function () { delete plugins.hooks.testHook;