diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md index 0e162b64513d2..060e061b5c284 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- added new `doActionAsync` and `applyFiltersAsync` functions to run hooks in async mode ([#64204](https://github.com/WordPress/gutenberg/pull/64204)). + ## 4.8.0 (2024-09-19) ## 4.7.0 (2024-09-05) diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 3e9897c79952c..f80d2e63af37b 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -41,7 +41,9 @@ One notable difference between the JS and PHP hooks API is that in the JS versio - `removeAllActions( 'hookName' )` - `removeAllFilters( 'hookName' )` - `doAction( 'hookName', arg1, arg2, moreArgs, finalArg )` +- `doActionAsync( 'hookName', arg1, arg2, moreArgs, finalArg )` - `applyFilters( 'hookName', content, arg1, arg2, moreArgs, finalArg )` +- `applyFiltersAsync( 'hookName', content, arg1, arg2, moreArgs, finalArg )` - `doingAction( 'hookName' )` - `doingFilter( 'hookName' )` - `didAction( 'hookName' )` diff --git a/packages/hooks/src/createCurrentHook.js b/packages/hooks/src/createCurrentHook.js index 634901fe55f63..3ada032249600 100644 --- a/packages/hooks/src/createCurrentHook.js +++ b/packages/hooks/src/createCurrentHook.js @@ -11,11 +11,8 @@ function createCurrentHook( hooks, storeKey ) { return function currentHook() { const hooksStore = hooks[ storeKey ]; - - return ( - hooksStore.__current[ hooksStore.__current.length - 1 ]?.name ?? - null - ); + const currentArray = Array.from( hooksStore.__current ); + return currentArray.at( -1 )?.name ?? null; }; } diff --git a/packages/hooks/src/createDoingHook.js b/packages/hooks/src/createDoingHook.js index 652ab06b4ba72..9fccf38171f33 100644 --- a/packages/hooks/src/createDoingHook.js +++ b/packages/hooks/src/createDoingHook.js @@ -24,13 +24,13 @@ function createDoingHook( hooks, storeKey ) { // If the hookName was not passed, check for any current hook. if ( 'undefined' === typeof hookName ) { - return 'undefined' !== typeof hooksStore.__current[ 0 ]; + return hooksStore.__current.size > 0; } - // Return the __current hook. - return hooksStore.__current[ 0 ] - ? hookName === hooksStore.__current[ 0 ].name - : false; + // Find if the `hookName` hook is in `__current`. + return Array.from( hooksStore.__current ).some( + ( hook ) => hook.name === hookName + ); }; } diff --git a/packages/hooks/src/createHooks.js b/packages/hooks/src/createHooks.js index 361383a3a97fc..1f9b1a8206b02 100644 --- a/packages/hooks/src/createHooks.js +++ b/packages/hooks/src/createHooks.js @@ -20,11 +20,11 @@ export class _Hooks { constructor() { /** @type {import('.').Store} actions */ this.actions = Object.create( null ); - this.actions.__current = []; + this.actions.__current = new Set(); /** @type {import('.').Store} filters */ this.filters = Object.create( null ); - this.filters.__current = []; + this.filters.__current = new Set(); this.addAction = createAddHook( this, 'actions' ); this.addFilter = createAddHook( this, 'filters' ); @@ -34,8 +34,10 @@ export class _Hooks { this.hasFilter = createHasHook( this, 'filters' ); this.removeAllActions = createRemoveHook( this, 'actions', true ); this.removeAllFilters = createRemoveHook( this, 'filters', true ); - this.doAction = createRunHook( this, 'actions' ); - this.applyFilters = createRunHook( this, 'filters', true ); + this.doAction = createRunHook( this, 'actions', false, false ); + this.doActionAsync = createRunHook( this, 'actions', false, true ); + this.applyFilters = createRunHook( this, 'filters', true, false ); + this.applyFiltersAsync = createRunHook( this, 'filters', true, true ); this.currentAction = createCurrentHook( this, 'actions' ); this.currentFilter = createCurrentHook( this, 'filters' ); this.doingAction = createDoingHook( this, 'actions' ); diff --git a/packages/hooks/src/createRunHook.js b/packages/hooks/src/createRunHook.js index c2bf6fd187ce0..f2a56dbdc0d71 100644 --- a/packages/hooks/src/createRunHook.js +++ b/packages/hooks/src/createRunHook.js @@ -3,15 +3,15 @@ * registered to a hook of the specified type, optionally returning the final * value of the call chain. * - * @param {import('.').Hooks} hooks Hooks instance. + * @param {import('.').Hooks} hooks Hooks instance. * @param {import('.').StoreKey} storeKey - * @param {boolean} [returnFirstArg=false] Whether each hook callback is expected to - * return its first argument. + * @param {boolean} returnFirstArg Whether each hook callback is expected to return its first argument. + * @param {boolean} async Whether the hook callback should be run asynchronously * * @return {(hookName:string, ...args: unknown[]) => undefined|unknown} Function that runs hook callbacks. */ -function createRunHook( hooks, storeKey, returnFirstArg = false ) { - return function runHooks( hookName, ...args ) { +function createRunHook( hooks, storeKey, returnFirstArg, async ) { + return function runHook( hookName, ...args ) { const hooksStore = hooks[ storeKey ]; if ( ! hooksStore[ hookName ] ) { @@ -42,26 +42,43 @@ function createRunHook( hooks, storeKey, returnFirstArg = false ) { currentIndex: 0, }; - hooksStore.__current.push( hookInfo ); - - while ( hookInfo.currentIndex < handlers.length ) { - const handler = handlers[ hookInfo.currentIndex ]; - - const result = handler.callback.apply( null, args ); - if ( returnFirstArg ) { - args[ 0 ] = result; + async function asyncRunner() { + try { + hooksStore.__current.add( hookInfo ); + let result = returnFirstArg ? args[ 0 ] : undefined; + while ( hookInfo.currentIndex < handlers.length ) { + const handler = handlers[ hookInfo.currentIndex ]; + result = await handler.callback.apply( null, args ); + if ( returnFirstArg ) { + args[ 0 ] = result; + } + hookInfo.currentIndex++; + } + return returnFirstArg ? result : undefined; + } finally { + hooksStore.__current.delete( hookInfo ); } - - hookInfo.currentIndex++; } - hooksStore.__current.pop(); - - if ( returnFirstArg ) { - return args[ 0 ]; + function syncRunner() { + try { + hooksStore.__current.add( hookInfo ); + let result = returnFirstArg ? args[ 0 ] : undefined; + while ( hookInfo.currentIndex < handlers.length ) { + const handler = handlers[ hookInfo.currentIndex ]; + result = handler.callback.apply( null, args ); + if ( returnFirstArg ) { + args[ 0 ] = result; + } + hookInfo.currentIndex++; + } + return returnFirstArg ? result : undefined; + } finally { + hooksStore.__current.delete( hookInfo ); + } } - return undefined; + return ( async ? asyncRunner : syncRunner )(); }; } diff --git a/packages/hooks/src/index.js b/packages/hooks/src/index.js index 653a9537145d9..1d13397e406c6 100644 --- a/packages/hooks/src/index.js +++ b/packages/hooks/src/index.js @@ -25,7 +25,7 @@ import createHooks from './createHooks'; */ /** - * @typedef {Record & {__current: Current[]}} Store + * @typedef {Record & {__current: Set}} Store */ /** @@ -48,7 +48,9 @@ const { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, @@ -70,7 +72,9 @@ export { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, diff --git a/packages/hooks/src/test/index.test.js b/packages/hooks/src/test/index.test.js index 9b7eb3b8e0e22..5fdaf5fc7207a 100644 --- a/packages/hooks/src/test/index.test.js +++ b/packages/hooks/src/test/index.test.js @@ -12,7 +12,9 @@ import { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, @@ -943,3 +945,151 @@ test( 'checking hasFilter with named callbacks and removeAllActions', () => { expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( false ); expect( hasFilter( 'test.filter', 'my_second_callback' ) ).toBe( false ); } ); + +describe( 'async filter', () => { + test( 'runs all registered handlers', async () => { + addFilter( 'test.async.filter', 'callback_plus1', ( value ) => { + return new Promise( ( r ) => + setTimeout( () => r( value + 1 ), 10 ) + ); + } ); + addFilter( 'test.async.filter', 'callback_times2', ( value ) => { + return new Promise( ( r ) => + setTimeout( () => r( value * 2 ), 10 ) + ); + } ); + + expect( await applyFiltersAsync( 'test.async.filter', 2 ) ).toBe( 6 ); + } ); + + test( 'aborts when handler throws an error', async () => { + const sqrt = jest.fn( async ( value ) => { + if ( value < 0 ) { + throw new Error( 'cannot pass negative value to sqrt' ); + } + return Math.sqrt( value ); + } ); + + const plus1 = jest.fn( async ( value ) => { + return value + 1; + } ); + + addFilter( 'test.async.filter', 'callback_sqrt', sqrt ); + addFilter( 'test.async.filter', 'callback_plus1', plus1 ); + + await expect( + applyFiltersAsync( 'test.async.filter', -1 ) + ).rejects.toThrow( 'cannot pass negative value to sqrt' ); + expect( sqrt ).toHaveBeenCalledTimes( 1 ); + expect( plus1 ).not.toHaveBeenCalled(); + } ); + + test( 'is correctly tracked by doingFilter and didFilter', async () => { + addFilter( 'test.async.filter', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter' ) ).toBe( true ); + return value; + } ); + + expect( doingFilter( 'test.async.filter' ) ).toBe( false ); + expect( didFilter( 'test.async.filter' ) ).toBe( 0 ); + await applyFiltersAsync( 'test.async.filter', 0 ); + expect( doingFilter( 'test.async.filter' ) ).toBe( false ); + expect( didFilter( 'test.async.filter' ) ).toBe( 1 ); + } ); + + test( 'is correctly tracked when multiple filters run at once', async () => { + addFilter( 'test.async.filter1', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter1' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + return value; + } ); + addFilter( 'test.async.filter2', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter2' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + return value; + } ); + + await Promise.all( [ + applyFiltersAsync( 'test.async.filter1', 0 ), + applyFiltersAsync( 'test.async.filter2', 0 ), + ] ); + } ); +} ); + +describe( 'async action', () => { + test( 'runs all registered handlers sequentially', async () => { + const outputs = []; + const action1 = async () => { + outputs.push( 1 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 2 ); + }; + + const action2 = async () => { + outputs.push( 3 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 4 ); + }; + + addAction( 'test.async.action', 'action1', action1 ); + addAction( 'test.async.action', 'action2', action2 ); + + await doActionAsync( 'test.async.action' ); + expect( outputs ).toEqual( [ 1, 2, 3, 4 ] ); + } ); + + test( 'aborts when handler throws an error', async () => { + const outputs = []; + const action1 = async () => { + throw new Error( 'aborting' ); + }; + + const action2 = async () => { + outputs.push( 3 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 4 ); + }; + + addAction( 'test.async.action', 'action1', action1 ); + addAction( 'test.async.action', 'action2', action2 ); + + await expect( doActionAsync( 'test.async.action' ) ).rejects.toThrow( + 'aborting' + ); + expect( outputs ).toEqual( [] ); + } ); + + test( 'is correctly tracked by doingAction and didAction', async () => { + addAction( 'test.async.action', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action' ) ).toBe( true ); + } ); + + expect( doingAction( 'test.async.action' ) ).toBe( false ); + expect( didAction( 'test.async.action' ) ).toBe( 0 ); + await doActionAsync( 'test.async.action', 0 ); + expect( doingAction( 'test.async.action' ) ).toBe( false ); + expect( didAction( 'test.async.action' ) ).toBe( 1 ); + } ); + + test( 'is correctly tracked when multiple actions run at once', async () => { + addAction( 'test.async.action1', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action1' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + } ); + addAction( 'test.async.action2', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action2' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + } ); + + await Promise.all( [ + doActionAsync( 'test.async.action1', 0 ), + doActionAsync( 'test.async.action2', 0 ), + ] ); + } ); +} );