Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce wp-on-async directive as performant alternative over synchronous wp-on directive #61885

Merged
merged 7 commits into from
May 28, 2024

Conversation

westonruter
Copy link
Member

@westonruter westonruter commented May 23, 2024

Fixes #61634.

What?

This adds an alternative async variation to the data-wp-on directives by tagging on an -async to the directive name. So instead of data-wp-on--click it can be data-wp-on-async--click. This is also done for the data-wp-on-window and data-wp-on-document directives with data-wp-on-async-window anddata-wp-on-async-document respectively (which here can also be made passive).

Why?

This ensures that when there are multiple directives for the same event, they do not add up to a long task.

How?

This directive yields to the main thread immediately before invoking the action/callback.

This is implemented for the eligible directives in the Image block.

Testing Instructions

  1. Install and activate this Try Slow Events plugin which adds 10 click event directives to the img in an Image block.
  2. Add an Image to a post and enable the click-to-expand feature.
  3. Navigate to the post on the frontend.
  4. First add ?try-slow-events=sync to the URL and try opening the image in a lightbox. Notice the latency.
  5. Second, try ?try-slow-events=async to the URL and try opening the image in a lightbox. Notice the latency goes away.

Screenshots or screencast

Before (?try-slow-events=sync)

Screen.recording.2024-05-22.17.34.29.webm

image

After (?try-slow-events=async)

Screen.recording.2024-05-22.17.34.52.webm

image

(Aside: The yieldToMain() seems it could be improved yet further since a long task is still sneaking in, even though it is much better.)

@westonruter westonruter added [Type] Enhancement A suggestion for improvement. [Type] Performance Related to performance efforts [Feature] Interactivity API API to add frontend interactivity to blocks. [Packages] Interactivity /packages/interactivity labels May 23, 2024
Copy link

github-actions bot commented May 23, 2024

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: cbravobernal <cbravobernal@git.wordpress.org>
Co-authored-by: gziolo <gziolo@git.wordpress.org>
Co-authored-by: luisherranz <luisherranz@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Copy link

This pull request has changed or added PHP files. Please confirm whether these changes need to be synced to WordPress Core, and therefore featured in the next release of WordPress.

If so, it is recommended to create a new Trac ticket and submit a pull request to the WordPress Core Github repository soon after this pull request is merged.

If you're unsure, you can always ask for help in the #core-editor channel in WordPress Slack.

Thank you! ❤️

View changed files
❔ packages/block-library/src/image/index.php
Copy link

github-actions bot commented May 23, 2024

Flaky tests detected in 9a350d3.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/9211662096
📝 Reported issues:

@cbravobernal cbravobernal removed the [Type] Enhancement A suggestion for improvement. label May 23, 2024
@cbravobernal
Copy link
Contributor

Thanks @westonruter for the PR!

My main concern is that developers won't use the async mode as such as needed.

What could be the implications of making it async by default and switching to wp-on-sync- for the rest of cases?

How can we guide developers for the best approach, apart from updating the docs?

cbravobernal
cbravobernal previously approved these changes May 23, 2024
Copy link
Contributor

@cbravobernal cbravobernal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks good, and tests are working as expected.

It would be nice to add some e2e or check if we can update the ones we have for the document and window directives.

We still need to update the documentation to include these new directives.

@cbravobernal cbravobernal self-requested a review May 23, 2024 12:28
@cbravobernal cbravobernal dismissed their stale review May 23, 2024 12:28

Forgot to add changelog

@cbravobernal cbravobernal force-pushed the add/data-wp-on-async-directive branch from fa2f799 to acab48d Compare May 23, 2024 12:32
@westonruter
Copy link
Member Author

westonruter commented May 23, 2024

@cbravobernal:

My main concern is that developers won't use the async mode as such as needed.

What could be the implications of making it async by default and switching to wp-on-sync- for the rest of cases?

That would be ideal, if the back-compat breakage is acceptable. Basically, synchronous events are required when passing in an event object and when the handler calls event.preventDefault(), event.stopPropagation(), or event.stopImmediatePropagation(). Two of the actions on the Image block call event.preventDefault() (as listed in #61634). For example, if actions.handleKeydown were to be made async, then no longer would Tab key presses get trapped in the opened lightbox since event.preventDefault() would be called too late.

How can we guide developers for the best approach, apart from updating the docs?

One possibility is that we warn users about switching to use on-async when the associated action lacks any argument. Perhaps when SCRIPT_DEBUG is enabled, when looping over the directives to add to the events Map, it could look up the referenced function and stringify it to then parse out the function's argument list. If it is empty and on is used, it could warn that the user should use on-async instead. (Maybe the function body could also be looked at to see if it contains preventDefault, stopPropagation, or stopImmediatePropagation.)

@westonruter
Copy link
Member Author

One possibility is that we warn users about switching to use on-async when the associated action lacks any argument. Perhaps when SCRIPT_DEBUG is enabled, when looping over the directives to add to the events Map, it could look up the referenced function and stringify it to then parse out the function's argument list. If it is empty and on is used, it could warn that the user should use on-async instead. (Maybe the function body could also be looked at to see if it contains preventDefault, stopPropagation, or stopImmediatePropagation.)

Humm. This doesn't seem to work as I expected because the actions/callbacks are getting wrapped in additional functions.

My failed patch
diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx
index a4a320eda57..7af341da558 100644
--- a/packages/interactivity/src/directives.tsx
+++ b/packages/interactivity/src/directives.tsx
@@ -19,7 +19,7 @@ import {
 	yieldToMain,
 } from './utils';
 import type { DirectiveEntry } from './hooks';
-import { directive, getScope, getEvaluate } from './hooks';
+import { directive, getScope, getEvaluate, getDereference } from './hooks';
 
 // Assigned objects should be ignored during proxification.
 const contextAssignedObjects = new WeakMap();
@@ -314,7 +314,7 @@ export default () => {
 	} );
 
 	// data-wp-on--[event]
-	directive( 'on', ( { directives: { on }, element, evaluate } ) => {
+	directive( 'on', ( { directives: { on }, element, evaluate, dereference } ) => {
 		const events = new Map< string, Set< DirectiveEntry > >();
 		on.filter( ( { suffix } ) => suffix !== 'default' ).forEach(
 			( entry ) => {
@@ -328,6 +328,14 @@ export default () => {
 
 		events.forEach( ( entries, eventType ) => {
 			const existingHandler = element.props[ `on${ eventType }` ];
+
+			if ( typeof SCRIPT_DEBUG === 'boolean' && SCRIPT_DEBUG ) {
+				for ( const entry of entries ) {
+					const handler = dereference( entry ).value;
+					console.info(handler.toString());
+				}
+			}
+
 			element.props[ `on${ eventType }` ] = ( event: Event ) => {
 				entries.forEach( ( entry ) => {
 					if ( existingHandler ) {
diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx
index d8aa43d2c0e..9fc403c7801 100644
--- a/packages/interactivity/src/hooks.tsx
+++ b/packages/interactivity/src/hooks.tsx
@@ -53,6 +53,11 @@ interface DirectiveArgs {
 	 * context.
 	 */
 	evaluate: Evaluate;
+	/**
+	 * Function that dereferences a given path to a value either in the store or the
+	 * context.
+	 */
+	dereference: Dereference;
 }
 
 interface DirectiveCallback {
@@ -71,6 +76,7 @@ interface DirectiveOptions {
 
 interface Scope {
 	evaluate: Evaluate;
+	dereference: Dereference;
 	context: object;
 	ref: RefObject< HTMLElement >;
 	attributes: createElement.JSX.HTMLAttributes;
@@ -80,10 +86,18 @@ interface Evaluate {
 	( entry: DirectiveEntry, ...args: any[] ): any;
 }
 
+interface Dereference {
+	( entry: DirectiveEntry ): any;
+}
+
 interface GetEvaluate {
 	( args: { scope: Scope } ): Evaluate;
 }
 
+interface GetDereference {
+	( args: { scope: Scope } ): Dereference;
+}
+
 type PriorityLevel = string[];
 
 interface GetPriorityLevels {
@@ -307,6 +321,24 @@ export const getEvaluate: GetEvaluate =
 		return hasNegationOperator ? ! result : result;
 	};
 
+// Generate the dereference function.
+export const getDereference: GetDereference =
+	( { scope } ) =>
+	( entry ) => {
+		let { value: path, namespace } = entry;
+		if ( typeof path !== 'string' ) {
+			throw new Error( 'The `value` prop should be a string path' );
+		}
+		// If path starts with !, remove it and save a flag.
+		const hasNegationOperator =
+			path[ 0 ] === '!' && !! ( path = path.slice( 1 ) );
+		setScope( scope );
+		const value = resolve( path, namespace );
+		const result = { value, hasNegationOperator };
+		resetScope();
+		return result;
+	};
+
 // Separate directives by priority. The resulting array contains objects
 // of directives grouped by same priority, and sorted in ascending order.
 const getPriorityLevels: GetPriorityLevels = ( directives ) => {
@@ -338,6 +370,7 @@ const Directives = ( {
 	// element ref, state and props.
 	const scope = useRef< Scope >( {} as Scope ).current;
 	scope.evaluate = useCallback( getEvaluate( { scope } ), [] );
+	scope.dereference = useCallback( getDereference( { scope } ), [] );
 	scope.context = useContext( context );
 	/* eslint-disable react-hooks/rules-of-hooks */
 	scope.ref = previousScope?.ref || useRef( null );
@@ -367,6 +400,7 @@ const Directives = ( {
 		element,
 		context,
 		evaluate: scope.evaluate,
+		dereference: scope.dereference,
 	};
 
 	setScope( scope );

When dereferencing a function, I'm seeing its source as a closure coming from handlers in store.ts:

(...args) => {
  (0,_hooks__WEBPACK_IMPORTED_MODULE_1__.setNamespace)(ns);
  try {
    return result(...args);
  } finally {
    (0,_hooks__WEBPACK_IMPORTED_MODULE_1__.resetNamespace)();
  }
}
@cbravobernal
Copy link
Contributor

We only have this week to land this change before beta 1.

Should we merge this, go with the data-wp-on-async directives and work later on the warning @westonruter ?

@westonruter
Copy link
Member Author

@cbravobernal That makes sense to me!

@gziolo
Copy link
Member

gziolo commented May 28, 2024

Let's remember to document the new API. It should be very similar to:

https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/interactivity-api/api-reference.md#wp-on

It would be also worth explaining the nuances and when to use wp-on-async instead of wp-on. The way I read it is - always use the async version when there is no event consumed.

@cbravobernal cbravobernal force-pushed the add/data-wp-on-async-directive branch from 9a350d3 to f498789 Compare May 28, 2024 10:50
@cbravobernal
Copy link
Contributor

I fixed the changelog conflict.

@cbravobernal cbravobernal enabled auto-merge (squash) May 28, 2024 10:52
@cbravobernal cbravobernal merged commit 9c95f91 into trunk May 28, 2024
61 checks passed
@cbravobernal cbravobernal deleted the add/data-wp-on-async-directive branch May 28, 2024 11:29
@github-actions github-actions bot added this to the Gutenberg 18.5 milestone May 28, 2024
@adamsilverstein adamsilverstein added the Needs Dev Note Requires a developer note for a major WordPress release cycle label May 28, 2024
@adamsilverstein
Copy link
Member

Let's remember to document the new API. It should be very similar to:

trunk/docs/reference-guides/interactivity-api/api-reference.md#wp-on

It would be also worth explaining the nuances and when to use wp-on-async instead of wp-on. The way I read it is - always use the async version when there is no event consumed.

I added the "Needs Dev Note label here - this feature is worth calling out for the 6.6 core release.

@westonruter
Copy link
Member Author

I've opened #62160 to implement data-wp-on-async for the other core blocks.

carstingaxion pushed a commit to carstingaxion/gutenberg that referenced this pull request Jun 4, 2024
…hronous `wp-on` directive (WordPress#61885)

* Improve typing for data-wp-on directive

* Move yieldToMain to utils

* fixup! Improve typing for data-wp-on directive

* Introduce on-async directives

* Use on-async directives in image block

* Update changelog

* Fix typo in comment

---------

Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: cbravobernal <cbravobernal@git.wordpress.org>
Co-authored-by: gziolo <gziolo@git.wordpress.org>
Co-authored-by: luisherranz <luisherranz@git.wordpress.org>
patil-vipul pushed a commit to patil-vipul/gutenberg that referenced this pull request Jun 17, 2024
…hronous `wp-on` directive (WordPress#61885)

* Improve typing for data-wp-on directive

* Move yieldToMain to utils

* fixup! Improve typing for data-wp-on directive

* Introduce on-async directives

* Use on-async directives in image block

* Update changelog

* Fix typo in comment

---------

Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: cbravobernal <cbravobernal@git.wordpress.org>
Co-authored-by: gziolo <gziolo@git.wordpress.org>
Co-authored-by: luisherranz <luisherranz@git.wordpress.org>
@cbravobernal
Copy link
Contributor

cbravobernal commented Jun 18, 2024

Dev note

WordPress 6.6 includes three new directives aimed at improving performance:

  • data-wp-on-async
  • data-wp-on-async-document
  • data-wp-on-async-window

These async directives optimize event callbacks by first yielding to the main thread. That way, complex interactions won't contribute to long tasks, improving the Interaction to Next Paint (INP). You can read more about it in Optimize Interaction to Next Paint.

These directives are recommended over the sync ones (data-wp-on, data-wp-on-document, and data-wp-on-window), but you can use them only when you don't need synchronous access to the event object, specifically if you need to call event.preventDefault(), event.stopPropagation(), or event.stopImmediatePropagation(). The directives in core blocks have been updated to use async where available.

When you must resort to using the non-async directives, the @wordpress/interactivity package now exports a splitTask function which can be used to manually split yield an action to the main thread after calling the synchronous event API. Please see the documentation on async actions for how to implement this.

@cbravobernal cbravobernal added has dev note when dev note is done (for upcoming WordPress release) and removed Needs Dev Note Requires a developer note for a major WordPress release cycle labels Jun 18, 2024
@westonruter
Copy link
Member Author

@cbravobernal Thanks for drafting that dev note! I've made some edits.

@westonruter
Copy link
Member Author

I just realized that I didn't export yieldToMain. Proposed in #62665

@cbravobernal
Copy link
Contributor

cbravobernal commented Jun 18, 2024

@cbravobernal Thanks for drafting that dev note! I've made some edits.

I initially put "that way" and GTP4 corrected me to "this way" 😆

@westonruter
Copy link
Member Author

@cbravobernal I've updated the dev note comment above to incorporate discussion of the new splitTask function and to fix the names of the directives (also updated in #62759).

@janusqa
Copy link
Contributor

janusqa commented Jul 15, 2024

Hi there. I've been looking at the docs here:
https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/api-reference/

I am using wp-on-window--message to listen for messages sent from an IFRAME to the parent window.
wp-on-window--message seems to work ok. I get the message, but when i switch over to wp-on-async-window--message no messages arrive.

I've also read this from the PR. Does it mean I'm too early to the party to be using this? The docs indicate 6.5 or higher.

WordPress 6.6 includes three new directives aimed at improving performance:

    data-wp-on-async
    data-wp-on-async-document
    data-wp-on-async-window

The particulars:

wordpress 6.5.5
"@wordpress/interactivity": "^6.3.0",
"@wordpress/scripts": "^27.9.0",

The basics

render.php

<div data-wp-on-async-window--message="actions.processPostMessage" id="<?php echo $block_unique_id ?>" class="the-view" data-wp-interactive="create-block" <?php echo wp_interactivity_data_wp_context($block_context) ?>>
<!-- some html goes here -->
</div>
<iframe class="iframe-container" name="theFrame"></iframe>

view.js

/**
 * WordPress dependencies
 */
import { store, getContext } from '@wordpress/interactivity';
import { longRunningAsyncFunction } from './thefunction';

store('create-block', {
    actions: {
        processPostMessage: function* (event) {
            if (event.origin !== 'http://localhost:9000') return;
            const context = getContext();
            event.data.elementArr.push({ theThing: context.the_thing });
            yield longRunningAsyncFunction(event);
        },
    },
    callbacks: {},
});
@westonruter
Copy link
Member Author

The async directives are new in 6.6.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Interactivity API API to add frontend interactivity to blocks. has dev note when dev note is done (for upcoming WordPress release) [Packages] Interactivity /packages/interactivity [Type] Performance Related to performance efforts
5 participants