diff --git a/docs/reference-guides/interactivity-api/api-reference.md b/docs/reference-guides/interactivity-api/api-reference.md index bbbb565684c578..dc1798860b01fb 100644 --- a/docs/reference-guides/interactivity-api/api-reference.md +++ b/docs/reference-guides/interactivity-api/api-reference.md @@ -873,6 +873,8 @@ const { state } = store( 'myPlugin', { } ); ``` +You may want to add multiple such `yield` points in your action if it is doing a lot of work. + As mentioned above with [`wp-on`](#wp-on), [`wp-on-window`](#wp-on-window), and [`wp-on-document`](#wp-on-document), an async action should be used whenever the `async` versions of the aforementioned directives cannot be used due to the action requiring synchronous access to the `event` object. Synchronous access is required whenever the action needs to call `event.preventDefault()`, `event.stopPropagation()`, or `event.stopImmediatePropagation()`. To ensure that the action code does not contribute to a long task, you may manually yield to the main thread after calling the synchronous event API. For example: ```js @@ -885,16 +887,17 @@ function splitTask() { store( 'myPlugin', { actions: { - handleClick: function* ( event ) { + handleClick: withSyncEvent( function* ( event ) { event.preventDefault(); yield splitTask(); doTheWork(); - }, + } ), }, } ); ``` -You may want to add multiple such `yield` points in your action if it is doing a lot of work. +You may notice the use of the [`withSyncEvent()`](#withsyncevent) utility function in this example. This is necessary due to an ongoing effort to handle store actions asynchronously by default, unless they require synchronous event access (which this example does due to the call to `event.preventDefault()`). Otherwise a deprecation warning will be triggered, and in a future release the behavior will change accordingly. + #### Side Effects @@ -1253,6 +1256,43 @@ store( 'mySliderPlugin', { } ); ``` +### withSyncEvent() + +Actions that require synchronous access to the `event` object need to use the `withSyncEvent()` function to wrap their handler callback. This is necessary due to an ongoing effort to handle store actions asynchronously by default, unless they require synchronous event access. Therefore, as of Gutenberg `TODO: Add release number here!` / WordPress 6.8 all actions that require synchronous event access need to use the `withSyncEvent()` utility wrapper function. Otherwise a deprecation warning will be triggered, and in a future release the behavior will change accordingly. + +Only very specific event methods and properties require synchronous access, so it is advised to only use `withSyncEvent()` when necessary. The following event methods and properties require synchronous access: + +* `event.currentTarget` +* `event.preventDefault()` +* `event.stopImmediatePropagation()` +* `event.stopPropagation()` + +Here is an example, where one action requires synchronous event access while the other actions do not: + +```js +// store +import { store, withSyncEvent } from '@wordpress/interactivity'; + +store( 'myPlugin', { + actions: { + // `event.preventDefault()` requires synchronous event access. + preventNavigation: withSyncEvent( ( event ) => { + event.preventDefault(); + } ), + + // `event.target` does not require synchronous event access. + logTarget: ( event ) => { + console.log( 'event target => ', event.target ); + }, + + // Not using `event` at all does not require synchronous event access. + logSomething: () => { + console.log( 'something' ); + }, + }, +} ); +``` + ## Server functions The Interactivity API comes with handy functions that allow you to initialize and reference configuration options on the server. This is necessary to feed the initial data that the Server Directive Processing will use to modify the HTML markup before it's send to the browser. It is also a great way to leverage many of WordPress's APIs, like nonces, AJAX, and translations. diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 3c9a729538813e..71a492a570b2ae 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { store, getContext, getElement } from '@wordpress/interactivity'; +import { + store, + getContext, + getElement, + withSyncEvent, +} from '@wordpress/interactivity'; /** * Tracks whether user is touching screen; used to differentiate behavior for @@ -128,7 +133,7 @@ const { state, actions, callbacks } = store( }, 450 ); } }, - handleKeydown( event ) { + handleKeydown: withSyncEvent( ( event ) => { if ( state.overlayEnabled ) { // Focuses the close button when the user presses the tab key. if ( event.key === 'Tab' ) { @@ -141,8 +146,8 @@ const { state, actions, callbacks } = store( actions.hideLightbox(); } } - }, - handleTouchMove( event ) { + } ), + handleTouchMove: withSyncEvent( ( event ) => { // On mobile devices, prevents triggering the scroll event because // otherwise the page jumps around when it resets the scroll position. // This also means that closing the lightbox requires that a user @@ -152,7 +157,7 @@ const { state, actions, callbacks } = store( if ( state.overlayEnabled ) { event.preventDefault(); } - }, + } ), handleTouchStart() { isTouching = true; }, diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 9da7ab70d84f33..fd1fe33537b2f5 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { store, getContext, getElement } from '@wordpress/interactivity'; +import { + store, + getContext, + getElement, + withSyncEvent, +} from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', @@ -106,7 +111,7 @@ const { state, actions } = store( actions.openMenu( 'click' ); } }, - handleMenuKeydown( event ) { + handleMenuKeydown: withSyncEvent( ( event ) => { const { type, firstFocusableElement, lastFocusableElement } = getContext(); if ( state.menuOpenedBy.click ) { @@ -137,7 +142,7 @@ const { state, actions } = store( } } } - }, + } ), handleMenuFocusout( event ) { const { modal, type } = getContext(); // If focus is outside modal, and in the document, close menu diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index e23294a24e02e3..fff12b16eac65b 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { store, getContext, getElement } from '@wordpress/interactivity'; +import { + store, + getContext, + getElement, + withSyncEvent, +} from '@wordpress/interactivity'; const isValidLink = ( ref ) => ref && @@ -22,7 +27,7 @@ store( 'core/query', { actions: { - *navigate( event ) { + navigate: withSyncEvent( function* ( event ) { const ctx = getContext(); const { ref } = getElement(); const queryRef = ref.closest( @@ -42,7 +47,7 @@ store( const firstAnchor = `.wp-block-post-template a[href]`; queryRef.querySelector( firstAnchor )?.focus(); } - }, + } ), *prefetch() { const { ref } = getElement(); if ( isValidLink( ref ) ) { diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js index 0e4c462a2e3213..617e179b1dc88a 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { store, getContext, getElement } from '@wordpress/interactivity'; +import { + store, + getContext, + getElement, + withSyncEvent, +} from '@wordpress/interactivity'; const { actions } = store( 'core/search', @@ -31,7 +36,7 @@ const { actions } = store( }, }, actions: { - openSearchInput( event ) { + openSearchInput: withSyncEvent( ( event ) => { const ctx = getContext(); const { ref } = getElement(); if ( ! ctx.isSearchInputVisible ) { @@ -39,7 +44,7 @@ const { actions } = store( ctx.isSearchInputVisible = true; ref.parentElement.querySelector( 'input' ).focus(); } - }, + } ), closeSearchInput() { const ctx = getContext(); ctx.isSearchInputVisible = false; diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js index 83f016e2eac16a..d9eb2005cef885 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js @@ -1,17 +1,22 @@ /** * WordPress dependencies */ -import { store, getContext, getServerContext } from '@wordpress/interactivity'; +import { + store, + getContext, + getServerContext, + withSyncEvent, +} from '@wordpress/interactivity'; store( 'test/get-server-context', { actions: { - *navigate( e ) { + navigate: withSyncEvent( function* ( e ) { e.preventDefault(); const { actions } = yield import( '@wordpress/interactivity-router' ); yield actions.navigate( e.target.href ); - }, + } ), attemptModification() { try { getServerContext().prop = 'updated from client'; diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js index db2992ec4a5863..23cd0c328aee60 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js @@ -1,17 +1,22 @@ /** * WordPress dependencies */ -import { store, getServerState, getContext } from '@wordpress/interactivity'; +import { + store, + getServerState, + getContext, + withSyncEvent, +} from '@wordpress/interactivity'; const { state } = store( 'test/get-server-state', { actions: { - *navigate( e ) { + navigate: withSyncEvent( function* ( e ) { e.preventDefault(); const { actions } = yield import( '@wordpress/interactivity-router' ); yield actions.navigate( e.target.href ); - }, + } ), attemptModification() { try { getServerState().prop = 'updated from client'; diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js index bd1d6e11647799..266a989ada7397 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store } from '@wordpress/interactivity'; +import { store, withSyncEvent } from '@wordpress/interactivity'; const { state } = store( 'router', { state: { @@ -18,7 +18,7 @@ const { state } = store( 'router', { }, }, actions: { - *navigate( e ) { + navigate: withSyncEvent( function* ( e ) { e.preventDefault(); state.navigations.count += 1; @@ -38,7 +38,7 @@ const { state } = store( 'router', { if ( state.navigations.pending === 0 ) { state.status = 'idle'; } - }, + } ), toggleTimeout() { state.timeout = state.timeout === 10000 ? 0 : 10000; }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js index f3468eb88aff01..a3a35d792755c2 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store, getContext } from '@wordpress/interactivity'; +import { store, getContext, withSyncEvent } from '@wordpress/interactivity'; const { state } = store( 'router-regions', { state: { @@ -17,13 +17,13 @@ const { state } = store( 'router-regions', { }, actions: { router: { - *navigate( e ) { + navigate: withSyncEvent( function* ( e ) { e.preventDefault(); const { actions } = yield import( '@wordpress/interactivity-router' ); yield actions.navigate( e.target.href ); - }, + } ), back() { history.back(); }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.js index 5b3b42f2b413e4..e15531c09518b5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.js @@ -1,20 +1,20 @@ /** * WordPress dependencies */ -import { store } from '@wordpress/interactivity'; +import { store, withSyncEvent } from '@wordpress/interactivity'; const { state } = store( 'test/router-styles', { state: { clientSideNavigation: false, }, actions: { - *navigate( e ) { + navigate: withSyncEvent( function* ( e ) { e.preventDefault(); const { actions } = yield import( '@wordpress/interactivity-router' ); yield actions.navigate( e.target.href ); state.clientSideNavigation = true; - }, + } ), }, } ); diff --git a/packages/interactivity-router/README.md b/packages/interactivity-router/README.md index efb52e59be2b5d..e3f96eb71d70b2 100644 --- a/packages/interactivity-router/README.md +++ b/packages/interactivity-router/README.md @@ -17,12 +17,13 @@ The package is intended to be imported dynamically in the `view.js` files of int ```js /* view.js */ -import { store } from '@wordpress/interactivity'; +import { store, withSyncEvent } from '@wordpress/interactivity'; // This is how you would typically use the navigate() action in your block. store( 'my-namespace/myblock', { actions: { - *goToPage( e ) { + // The withSyncEvent() utility needs to be used because preventDefault() requires synchronous event access. + goToPage: withSyncEvent( function* ( e ) { e.preventDefault(); // We import the package dynamically to reduce the initial JS bundle size. @@ -31,7 +32,7 @@ store( 'my-namespace/myblock', { '@wordpress/interactivity-router' ); yield actions.navigate( e.target.href ); - }, + } ), }, } ); ``` diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index bddd017b1c99db..c89aca282e079f 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -50,6 +50,54 @@ function deepClone< T >( source: T ): T { return source; } +/** + * Wraps event object to warn about access of synchronous properties and methods. + * + * For all store actions attached to an event listener the event object is proxied via this function, unless the action + * uses the `withSyncEvent()` utility to indicate that it requires synchronous access to the event object. + * + * At the moment, the proxied event only emits warnings when synchronous properties or methods are being accessed. In + * the future this will be changed and result in an error. The current temporary behavior allows implementers to update + * their relevant actions to use `withSyncEvent()`. + * + * For additional context, see https://github.com/WordPress/gutenberg/issues/64944. + * + * @param event Event object. + * @return Proxied event object. + */ +function wrapEventAsync( event: Event ) { + const handler = { + get( target: Event, prop: string | symbol, receiver: any ) { + const value = target[ prop ]; + switch ( prop ) { + case 'currentTarget': + warn( + `Accessing the synchronous event.${ prop } property in a store action without wrapping it in withSyncEvent() is deprecated and will stop working in WordPress 6.9. Please wrap the store action in withSyncEvent().` + ); + break; + case 'preventDefault': + case 'stopImmediatePropagation': + case 'stopPropagation': + warn( + `Using the synchronous event.${ prop }() function in a store action without wrapping it in withSyncEvent() is deprecated and will stop working in WordPress 6.9. Please wrap the store action in withSyncEvent().` + ); + break; + } + if ( value instanceof Function ) { + return function ( this: any, ...args: any[] ) { + return value.apply( + this === receiver ? target : this, + args + ); + }; + } + return value; + }, + }; + + return new Proxy( event, handler ); +} + const newRule = /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; const ruleClean = /\/\*[^]*?\*\/| +/g; @@ -96,13 +144,19 @@ const cssStringToObject = ( const getGlobalEventDirective = ( type: 'window' | 'document' ): DirectiveCallback => { - return ( { directives, evaluate } ) => { + return ( { directives, resolveEntry, evaluateResolved } ) => { directives[ `on-${ type }` ] .filter( isNonDefaultDirectiveSuffix ) .forEach( ( entry ) => { const eventName = entry.suffix.split( '--', 1 )[ 0 ]; useInit( () => { - const cb = ( event: Event ) => evaluate( entry, event ); + const cb = ( event: Event ) => { + const resolved = resolveEntry( entry ); + if ( ! resolved.value?.sync ) { + event = wrapEventAsync( event ); + } + evaluateResolved( resolved, event ); + }; const globalVar = type === 'window' ? window : document; globalVar.addEventListener( eventName, cb ); return () => globalVar.removeEventListener( eventName, cb ); @@ -263,51 +317,58 @@ export default () => { } ); // data-wp-on--[event] - directive( 'on', ( { directives: { on }, element, evaluate } ) => { - const events = new Map< string, Set< DirectiveEntry > >(); - on.filter( isNonDefaultDirectiveSuffix ).forEach( ( entry ) => { - const event = entry.suffix.split( '--' )[ 0 ]; - if ( ! events.has( event ) ) { - events.set( event, new Set< DirectiveEntry >() ); - } - events.get( event )!.add( entry ); - } ); + directive( + 'on', + ( { directives: { on }, element, resolveEntry, evaluateResolved } ) => { + const events = new Map< string, Set< DirectiveEntry > >(); + on.filter( isNonDefaultDirectiveSuffix ).forEach( ( entry ) => { + const event = entry.suffix.split( '--' )[ 0 ]; + if ( ! events.has( event ) ) { + events.set( event, new Set< DirectiveEntry >() ); + } + events.get( event )!.add( entry ); + } ); - events.forEach( ( entries, eventType ) => { - const existingHandler = element.props[ `on${ eventType }` ]; - element.props[ `on${ eventType }` ] = ( event: Event ) => { - entries.forEach( ( entry ) => { - if ( existingHandler ) { - existingHandler( event ); - } - let start; - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( globalThis.SCRIPT_DEBUG ) { - start = performance.now(); + events.forEach( ( entries, eventType ) => { + const existingHandler = element.props[ `on${ eventType }` ]; + element.props[ `on${ eventType }` ] = ( event: Event ) => { + entries.forEach( ( entry ) => { + if ( existingHandler ) { + existingHandler( event ); } - } - evaluate( entry, event ); - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( globalThis.SCRIPT_DEBUG ) { - performance.measure( - `interactivity api on ${ entry.namespace }`, - { - // eslint-disable-next-line no-undef - start, - end: performance.now(), - detail: { - devtools: { - track: `IA: on ${ entry.namespace }`, + let start; + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( globalThis.SCRIPT_DEBUG ) { + start = performance.now(); + } + } + const resolved = resolveEntry( entry ); + if ( ! resolved.value?.sync ) { + event = wrapEventAsync( event ); + } + evaluateResolved( resolved, event ); + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( globalThis.SCRIPT_DEBUG ) { + performance.measure( + `interactivity api on ${ entry.namespace }`, + { + // eslint-disable-next-line no-undef + start, + end: performance.now(), + detail: { + devtools: { + track: `IA: on ${ entry.namespace }`, + }, }, - }, - } - ); + } + ); + } } - } - } ); - }; - } ); - } ); + } ); + }; + } ); + } + ); // data-wp-on-async--[event] directive( diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 7899e3eafd2281..fa56e8ece6a976 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -74,6 +74,16 @@ interface DirectiveArgs { * context. */ evaluate: Evaluate; + /** + * Function that resolves a given path to value data in the store or the + * context. + */ + resolveEntry: ResolveEntry; + /** + * Function that evaluates resolved value data to a value either in the store + * or the context. + */ + evaluateResolved: EvaluateResolved; } export interface DirectiveCallback { @@ -90,6 +100,17 @@ interface DirectiveOptions { priority?: number; } +interface ResolvedEntry { + /** + * Resolved value, either a result or a function to get the result. + */ + value: any; + /** + * Whether a negation operator should be used on the result. + */ + hasNegationOperator: boolean; +} + export interface Evaluate { ( entry: DirectiveEntry, ...args: any[] ): any; } @@ -98,6 +119,22 @@ interface GetEvaluate { ( args: { scope: Scope } ): Evaluate; } +export interface ResolveEntry { + ( entry: DirectiveEntry ): ResolvedEntry; +} + +interface GetResolveEntry { + ( args: { scope: Scope } ): ResolveEntry; +} + +export interface EvaluateResolved { + ( resolved: ResolvedEntry, ...args: any[] ): any; +} + +interface GetEvaluateResolved { + ( args: { scope: Scope } ): EvaluateResolved; +} + type PriorityLevel = string[]; interface GetPriorityLevels { @@ -229,9 +266,19 @@ const resolve = ( path: string, namespace: string ) => { }; // Generate the evaluate function. -export const getEvaluate: GetEvaluate = +export const getEvaluate: GetEvaluate = ( scopeData ) => { + const resolveEntry = getResolveEntry( scopeData ); + const evaluateResolved = getEvaluateResolved( scopeData ); + return ( entry, ...args ) => { + const resolved = resolveEntry( entry ); + return evaluateResolved( resolved, ...args ); + }; +}; + +// Generate the resolveEntry function. +export const getResolveEntry: GetResolveEntry = ( { scope } ) => - ( entry, ...args ) => { + ( entry ) => { let { value: path, namespace } = entry; if ( typeof path !== 'string' ) { throw new Error( 'The `value` prop should be a string path' ); @@ -241,6 +288,19 @@ export const getEvaluate: GetEvaluate = path[ 0 ] === '!' && !! ( path = path.slice( 1 ) ); setScope( scope ); const value = resolve( path, namespace ); + resetScope(); + return { + value, + hasNegationOperator, + }; + }; + +// Generate the evaluateResolved function. +export const getEvaluateResolved: GetEvaluateResolved = + ( { scope } ) => + ( resolved, ...args ) => { + const { value, hasNegationOperator } = resolved; + setScope( scope ); const result = typeof value === 'function' ? value( ...args ) : value; resetScope(); return hasNegationOperator ? ! result : result; @@ -277,6 +337,11 @@ const Directives = ( { // element ref, state and props. const scope = useRef< Scope >( {} as Scope ).current; scope.evaluate = useCallback( getEvaluate( { scope } ), [] ); + scope.resolveEntry = useCallback( getResolveEntry( { scope } ), [] ); + scope.evaluateResolved = useCallback( + getEvaluateResolved( { scope } ), + [] + ); const { client, server } = useContext( context ); scope.context = client; scope.serverContext = server; @@ -308,6 +373,8 @@ const Directives = ( { element, context, evaluate: scope.evaluate, + resolveEntry: scope.resolveEntry, + evaluateResolved: scope.evaluateResolved, }; setScope( scope ); diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 9d013e4e744ed5..b7d68fd2007055 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -27,6 +27,7 @@ export { useCallback, useMemo, splitTask, + withSyncEvent, } from './utils'; export { useState, useRef } from 'preact/hooks'; diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts index 96e23c86ade73f..51996535f03368 100644 --- a/packages/interactivity/src/scopes.ts +++ b/packages/interactivity/src/scopes.ts @@ -7,10 +7,12 @@ import type { h as createElement, RefObject } from 'preact'; * Internal dependencies */ import { getNamespace } from './namespaces'; -import type { Evaluate } from './hooks'; +import type { Evaluate, ResolveEntry, EvaluateResolved } from './hooks'; export interface Scope { evaluate: Evaluate; + resolveEntry: ResolveEntry; + evaluateResolved: EvaluateResolved; context: object; serverContext: object; ref: RefObject< HTMLElement >; diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index d894d37a7b84bc..57e7c68a456ba6 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -30,6 +30,10 @@ declare global { } } +interface SyncAwareFunction extends Function { + sync?: boolean; +} + /** * Executes a callback function after the next frame is rendered. * @@ -135,11 +139,14 @@ export function withScope< ? Promise< Return > : never; export function withScope< Func extends Function >( func: Func ): Func; +export function withScope< Func extends SyncAwareFunction >( func: Func ): Func; export function withScope( func: ( ...args: unknown[] ) => unknown ) { const scope = getScope(); const ns = getNamespace(); + + let wrapped: Function; if ( func?.constructor?.name === 'GeneratorFunction' ) { - return async ( ...args: Parameters< typeof func > ) => { + wrapped = async ( ...args: Parameters< typeof func > ) => { const gen = func( ...args ) as Generator; let value: any; let it: any; @@ -171,17 +178,28 @@ export function withScope( func: ( ...args: unknown[] ) => unknown ) { return value; }; + } else { + wrapped = ( ...args: Parameters< typeof func > ) => { + setNamespace( ns ); + setScope( scope ); + try { + return func( ...args ); + } finally { + resetNamespace(); + resetScope(); + } + }; } - return ( ...args: Parameters< typeof func > ) => { - setNamespace( ns ); - setScope( scope ); - try { - return func( ...args ); - } finally { - resetNamespace(); - resetScope(); - } - }; + + // If function was annotated via `withSyncEvent()`, maintain the annotation. + const syncAware = func as SyncAwareFunction; + if ( syncAware.sync ) { + const syncAwareWrapped = wrapped as SyncAwareFunction; + syncAwareWrapped.sync = true; + return syncAwareWrapped; + } + + return wrapped; } /** @@ -374,3 +392,26 @@ export const isPlainObject = ( typeof candidate === 'object' && candidate.constructor === Object ); + +/** + * Indicates that the passed `callback` requires synchronous access to the event object. + * + * @param callback The event callback. + * @return Wrapped event callback. + */ +export function withSyncEvent( callback: Function ): SyncAwareFunction { + let wrapped: SyncAwareFunction; + + if ( callback?.constructor?.name === 'GeneratorFunction' ) { + wrapped = function* ( this: any, ...args: any[] ) { + yield* callback.apply( this, args ); + }; + } else { + wrapped = function ( this: any, ...args: any[] ) { + return callback.apply( this, args ); + }; + } + + wrapped.sync = true; + return wrapped; +}