diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index bddd017b1c99db..e2fea91a0c4d38 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -50,6 +50,39 @@ function deepClone< T >( source: T ): T { return source; } +function wrapEventAsync( event: Event ) { + const handler = { + get( target: any, prop: any, 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 +129,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 +302,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 722305f6bee112..d9734b695e75e7 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 ab6b0074727ee7..853287187cee8a 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -374,3 +374,15 @@ 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 const withSyncEvent = ( callback: Function ): Function => { + const wrapped = ( ...args: any[] ) => callback( ...args ); + wrapped.sync = true; + return wrapped; +};