From 60448fe5fd83d801816ca413634929123abcd364 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 18 Dec 2024 06:13:34 -0800 Subject: [PATCH 1/7] Implement withSyncEvent action wrapper utility. --- packages/interactivity/src/utils.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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; +}; From dba93ec4f7bb617d1f12ba1a06acfa39ff33d4b3 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 18 Dec 2024 06:14:23 -0800 Subject: [PATCH 2/7] Prepare Interactivity API infrastructure for awareness of action prior to evaluating it. --- packages/interactivity/src/directives.tsx | 99 +++++++++++++---------- packages/interactivity/src/hooks.tsx | 71 +++++++++++++++- packages/interactivity/src/index.ts | 1 + packages/interactivity/src/scopes.ts | 4 +- 4 files changed, 129 insertions(+), 46 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index bddd017b1c99db..ecac89a32be2e1 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -96,13 +96,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 ) { + // TODO: Wrap event in proxy. + } + evaluateResolved( resolved, event ); + }; const globalVar = type === 'window' ? window : document; globalVar.addEventListener( eventName, cb ); return () => globalVar.removeEventListener( eventName, cb ); @@ -263,51 +269,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 ) { + // TODO: Wrap event in proxy. + } + 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 >; From dad7083bc8b85d03955be237e21d7c619117692d Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 2 Jan 2025 14:58:20 -0800 Subject: [PATCH 3/7] Proxy event object when withSyncEvent() is not used. --- packages/interactivity/src/directives.tsx | 37 +++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index ecac89a32be2e1..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; @@ -105,7 +138,7 @@ const getGlobalEventDirective = ( const cb = ( event: Event ) => { const resolved = resolveEntry( entry ); if ( ! resolved.value?.sync ) { - // TODO: Wrap event in proxy. + event = wrapEventAsync( event ); } evaluateResolved( resolved, event ); }; @@ -296,7 +329,7 @@ export default () => { } const resolved = resolveEntry( entry ); if ( ! resolved.value?.sync ) { - // TODO: Wrap event in proxy. + event = wrapEventAsync( event ); } evaluateResolved( resolved, event ); if ( globalThis.IS_GUTENBERG_PLUGIN ) { From 21e51be1298a268b175a8f1d7d4daf6af6ab1361 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 21 Jan 2025 13:36:23 -0800 Subject: [PATCH 4/7] Ensure generator functions using withSyncEvent() are wrapped correctly to still be recognized as generator functions. --- packages/interactivity/src/utils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index 75438c0afaccb8..3ed58bd4c7d28b 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -382,6 +382,14 @@ export const isPlainObject = ( * @return Wrapped event callback. */ export const withSyncEvent = ( callback: Function ): Function => { + if ( callback?.constructor?.name === 'GeneratorFunction' ) { + const wrapped = function* ( ...args: any[] ) { + yield* callback( ...args ); + }; + wrapped.sync = true; + return wrapped; + } + const wrapped = ( ...args: any[] ) => callback( ...args ); wrapped.sync = true; return wrapped; From a582f01b2308dc58aa66e072f712cdfa3379f88f Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 21 Jan 2025 13:46:00 -0800 Subject: [PATCH 5/7] Update Interactivity API documentation to reference withSyncEvent(). --- .../interactivity-api/api-reference.md | 46 +++++++++++++++++-- packages/interactivity-router/README.md | 7 +-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/docs/reference-guides/interactivity-api/api-reference.md b/docs/reference-guides/interactivity-api/api-reference.md index bbbb565684c578..5aef537a247b3c 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. 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 event access 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 should 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 one does 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/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 ); - }, + } ), }, } ); ``` From 5d68fcb8226bb2dbc7473757bd5c586df3ee3654 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 21 Jan 2025 13:46:45 -0800 Subject: [PATCH 6/7] Use withSyncEvent() in all built-in actions that require it. --- packages/block-library/src/image/view.js | 15 ++++++++++----- packages/block-library/src/navigation/view.js | 11 ++++++++--- packages/block-library/src/query/view.js | 11 ++++++++--- packages/block-library/src/search/view.js | 11 ++++++++--- .../interactive-blocks/get-server-context/view.js | 11 ++++++++--- .../interactive-blocks/get-server-state/view.js | 11 ++++++++--- .../interactive-blocks/router-navigate/view.js | 6 +++--- .../interactive-blocks/router-regions/view.js | 6 +++--- .../router-styles-wrapper/view.js | 6 +++--- 9 files changed, 59 insertions(+), 29 deletions(-) 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; - }, + } ), }, } ); From 621a9c9eba00c69edaab5ebc89be2ccd93b8954a Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 22 Jan 2025 13:26:18 -0800 Subject: [PATCH 7/7] Minor fixes for withSyncEvent docs. --- docs/reference-guides/interactivity-api/api-reference.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference-guides/interactivity-api/api-reference.md b/docs/reference-guides/interactivity-api/api-reference.md index 5aef537a247b3c..5b2e3788141c65 100644 --- a/docs/reference-guides/interactivity-api/api-reference.md +++ b/docs/reference-guides/interactivity-api/api-reference.md @@ -1258,7 +1258,7 @@ store( 'mySliderPlugin', { ### withSyncEvent() -Actions that require synchronous event access 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 should use the `withSyncEvent()` utility wrapper function. Otherwise a deprecation warning will be triggered, and in a future release the behavior will change accordingly. +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: @@ -1267,7 +1267,7 @@ Only very specific event methods and properties require synchronous access, so i * `event.stopImmediatePropagation()` * `event.stopPropagation()` -Here is an example, where one action requires synchronous event access while the other one does not: +Here is an example, where one action requires synchronous event access while the other actions do not: ```js // store