Skip to content

Commit

Permalink
Hooks: add support for async filters and actions (#64204)
Browse files Browse the repository at this point in the history
* Hooks: add support for async filters and actions

* Unit tests for doing/didAction/Filter
  • Loading branch information
jsnajdr authored and gutenbergplugin committed Sep 29, 2024
1 parent ef84f51 commit f42a8d0
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 35 deletions.
4 changes: 4 additions & 0 deletions packages/hooks/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' )`
Expand Down
7 changes: 2 additions & 5 deletions packages/hooks/src/createCurrentHook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

Expand Down
10 changes: 5 additions & 5 deletions packages/hooks/src/createDoingHook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
};
}

Expand Down
10 changes: 6 additions & 4 deletions packages/hooks/src/createHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand All @@ -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' );
Expand Down
57 changes: 37 additions & 20 deletions packages/hooks/src/createRunHook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ] ) {
Expand Down Expand Up @@ -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 )();
};
}

Expand Down
6 changes: 5 additions & 1 deletion packages/hooks/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import createHooks from './createHooks';
*/

/**
* @typedef {Record<string, Hook> & {__current: Current[]}} Store
* @typedef {Record<string, Hook> & {__current: Set<Current>}} Store
*/

/**
Expand All @@ -48,7 +48,9 @@ const {
removeAllActions,
removeAllFilters,
doAction,
doActionAsync,
applyFilters,
applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
Expand All @@ -70,7 +72,9 @@ export {
removeAllActions,
removeAllFilters,
doAction,
doActionAsync,
applyFilters,
applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
Expand Down
150 changes: 150 additions & 0 deletions packages/hooks/src/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
removeAllActions,
removeAllFilters,
doAction,
doActionAsync,
applyFilters,
applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
Expand Down Expand Up @@ -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 ),
] );
} );
} );

0 comments on commit f42a8d0

Please sign in to comment.