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 withSyncEvent action wrapper utility and proxy event object whenever it is not used #68097

Open
wants to merge 22 commits into
base: trunk
Choose a base branch
from

Conversation

felixarntz
Copy link
Member

@felixarntz felixarntz commented Dec 18, 2024

This implements the 1st step from #64944. Please see the issue for additional context on the rationale and overarching approach for this.

Relevant technical choices

  • In order for directive implementations to access resolved data from the entries, a few new functions are needed. This is because the evaluate function resolves the entry into the relevant data, but then also immediately evaluates it.
    • For the case of an event listener callback, this means the evaluate function will not only "look up" the callback, but also call it.
    • Since we need to conditionally alter the event object based on whether the callback is wrapped in withSyncEvent prior to calling the callback, the evaluate function is not sufficient.
    • In a future PR, we would then also need to call splitTask() before running the callback function (see 2nd step from Introduce withSyncEvent and require Interactivity API actions that use the event object to use it #64944).
  • As a solution, this PR introduces two new functions resolveEntry and evaluateResolved. If they are called right after each other, it's effectively the same as evaluate. In fact, this PR updates evaluate to rely on these two lower-level functions, to avoid duplicate code.
  • Most directives can still use evaluate as is, which is more ergonomic. However the on (but not on-async) directives require the more granular access of the callback first, to only execute it later.

Note to reviewers

Because several files are touched, but only some specific changes are non-trivial, I would advise to review the commits individually at first. That should help convey the bigger picture than looking at the whole PR diff at once.

Testing instructions

  1. Add an image block to a post or page and enable the lightbox for it.
  2. In the frontend, after opening the lightbox, navigate using the Tab key and ensure no warnings are present in the browser console.
    • This is because the handleKeydown action of the core/image block's store passes its callback through withSyncEvent.
  3. You can now alter the code to see what would happen if the handleKeydown action was doing it wrong: Edit packages/block-library/src/image/view.js and remove the withSyncEvent usage from the handleKeydown callback, so that it is no longer wrapped. Don't forget to rerun the JS build.
  4. Repeat the frontend interaction from step 2, and you should see a warning in the browser console that withSyncEvent needs to be used.
  5. Add a search block to a post or page and set its button position to "button only".
  6. In the frontend, click the Search button and ensure no warnings are present in the browser console.
    • This is because the openSearchInput action of the core/search block's store passes its callback through withSyncEvent.
  7. Similar to step 3, edit packages/block-library/src/search/view.js and remove the withSyncEvent usage from the openSearchInput callback, so that it is no longer wrapped. Don't forget to rerun the JS build.
  8. Repeat the frontend interaction from step 6, and you should see a warning in the browser console that withSyncEvent needs to be used.

@felixarntz felixarntz added [Feature] Interactivity API API to add frontend interactivity to blocks. [Packages] Interactivity /packages/interactivity [Type] Enhancement A suggestion for improvement. [Type] Performance Related to performance efforts and removed [Feature] Interactivity API API to add frontend interactivity to blocks. [Type] Enhancement A suggestion for improvement. labels Dec 18, 2024
Copy link

github-actions bot commented Dec 18, 2024

Flaky tests detected in f0fa92a.
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/13245243863
📝 Reported issues:

@gziolo
Copy link
Member

gziolo commented Dec 23, 2024

So far, I have not implemented the actual proxying yet. I would like to get a sense of whether this minor expansion of the Interactivity API infrastructure makes sense before continuing.

I don't know the codebase, but you are on track as much as I understand the changes and their implications. Thank you for continuing to work on it. I also wanted to share that it might not be easy to get more feedback in the next 2-3 weeks as many folks, myself included, take longer holiday breaks 🎉

Copy link
Contributor

@michalczaplinski michalczaplinski left a comment

Choose a reason for hiding this comment

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

I gave it an initial review and so far it's looking good. The approach of splitting evaluate into 2 functions seems sound to me. I see that you haven't implemented the proxying yet, so I'm happy to take another look once that's closer to being ready.

PS. The Interactivity API is almost entirely tested using the e2e suite in test/e2e/specs/interactivity/. For this PR, you'll probably need to modify some of the existing "test" blocks like packages/e2e-tests/plugins/interactive-blocks/directive-on/view.js or create a new "test" block in that folder.

@felixarntz felixarntz marked this pull request as ready for review January 2, 2025 23:35
Copy link

github-actions bot commented Jan 2, 2025

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: felixarntz <[email protected]>
Co-authored-by: michalczaplinski <[email protected]>
Co-authored-by: westonruter <[email protected]>
Co-authored-by: luisherranz <[email protected]>
Co-authored-by: gziolo <[email protected]>

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

@felixarntz
Copy link
Member Author

felixarntz commented Jan 2, 2025

I have implemented the event proxying logic. For now, I'm warning about accessing the following without withSyncEvent():

  • event.currentTarget
  • event.preventDefault()
  • event.stopImmediatePropagation()
  • event.stopPropagation()

As discussed on the issue, there may be more properties and methods to warn about, but we could always expand later. I think these 4 are a good starting point and should cover what's most common.

Keep in mind that for now there is only a warning, but no functional behavior change. We can only yield before all callbacks that aren't using withSyncEvent() after at least one release cycle (which is why the warning message mentions WordPress 6.9, as this enhancement ideally launches as part of WordPress 6.8).

Review much appreciated!

@michalczaplinski michalczaplinski self-requested a review January 6, 2025 19:30
@felixarntz
Copy link
Member Author

Any chance I can get 1-2 reviews for the PR this week? That would be super helpful so we could ideally land this in Gutenberg soon. Thank you! 🙌

docs/reference-guides/interactivity-api/api-reference.md Outdated Show resolved Hide resolved
packages/interactivity/src/utils.ts Outdated Show resolved Hide resolved
packages/interactivity/src/utils.ts Outdated Show resolved Hide resolved
packages/interactivity/src/directives.tsx Show resolved Hide resolved
packages/interactivity/src/directives.tsx Outdated Show resolved Hide resolved
packages/interactivity/src/directives.tsx Show resolved Hide resolved
packages/interactivity/src/utils.ts Outdated Show resolved Hide resolved
packages/interactivity/src/directives.tsx Show resolved Hide resolved
@felixarntz
Copy link
Member Author

I have done some extensive testing on the code changes and added testing instructions to the PR description. I identified one crucial bug, which was fixed (see #68097 (comment)). But everything is working well now, and you can work through the testing instructions to validate.

Copy link
Member

@westonruter westonruter left a comment

Choose a reason for hiding this comment

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

LGTM, but I defer to deeper review from the Interactivity API team.

@felixarntz
Copy link
Member Author

Any chance you can take another look at this @michalczaplinski?

Copy link
Member

@luisherranz luisherranz left a comment

Choose a reason for hiding this comment

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

Looks good to me! 🙂

I only have one request: instead of creating two separate functions, we can pass an options object to evaluate. It's a pattern we're already using in other parts of the Interactivity API. This way, we not only simplify the code, but we also open the door to adding more options in the future in a simple way.

As evaluate is not publicly exposed yet, we can introduce breaking changes without any issues.

Perhaps we could start with these two options:

const result = evaluate( entry, { 
  resolveFunctions: false,
  arguments: [ ... ], // Arguments passed to the resolved function.
} );

Or we could even not add the option of arguments yet and, if someone needs to pass arguments, use the option resolveFunctions: false:

const cb = evaluate( entry, { resolveFunctions: false } );
cb( event );

EDIT: I guess e2e tests can be added once we move from deprecation to error, right? What do you think?

Comment on lines 402 to 417
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;
}
Copy link
Member

Choose a reason for hiding this comment

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

Why don't you simply add the sync property to the original function? Do you want to prevent modifying it?

Suggested change
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;
}
export function withSyncEvent( callback: Function ): SyncAwareFunction {
( callback as SyncAwareFunction ).sync = true;
return callback as SyncAwareFunction;
}

Copy link
Member Author

Choose a reason for hiding this comment

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

Great idea! I thought initially that wrapping it was safer than directly altering it, but since the callback function would be usually just declared anonymously and immediately passed to withSyncEvent, there's probably no side effects of directly altering it.

Most importantly, I agree with your suggestion because it makes the logic much simpler and more error-proof, maintaining whichever type the original function had before passing it to withSyncEvent, instead of trying to recreate/guess it.

I've amended this in f0fa92a, alongside minor wording adjustments based on the change.

Copy link
Member

Choose a reason for hiding this comment

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

Great idea! I thought initially that wrapping it was safer than directly altering it, but since the callback function would be usually just declared anonymously and immediately passed to withSyncEvent, there's probably no side effects of directly altering it.

The key word here is usually, right? You could define a named function in the module and then reference it here in withSyncEvent(). What if you then use that same function for an async event? Then it would eventually result in that code not being able run asynchronously, since the function has the sync flag set on it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah I agree it wouldn't be 100% guaranteed. But given the wrapping requires other quirks that also don't make it 100% reliable (not every function is a plain function or has constructor GeneratorFunction), I think avoiding the wrapping is safer for the vast majority of use-cases.

Specifically to your example, I have a hard time seeing that being common: First, because if the function is the same and needs synchronous event access, why would it elsewhere not access a synchronous event property? Second, all the Interactivity API examples and Core code define the actions right on the object, so it's not encouraged to do it differently anywhere.

Copy link
Member

Choose a reason for hiding this comment

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

I also think that the likelihood of the same function being executed synchronously and asynchronously at the same time must be really low, because it would have to include conditional code and depend on the place of the DOM where it is executed. It is not impossible and I'm sure there is some case, but in that case, maybe it would even be safer to always run it synchronously.

That said, I don't oppose leaving that part of the code as it is. Up to you 🙂

@felixarntz
Copy link
Member Author

felixarntz commented Feb 10, 2025

@luisherranz Thanks for the review!

I only have one request: instead of creating two separate functions, we can pass an options object to evaluate. [...]

Hmm, I'm not sure about that idea, given that the resolveFunctions option would significantly change what the function does and give it different code paths. Boolean arguments like this tend to increase complexity, see for instance https://medium.com/@amlcurran/clean-code-the-curse-of-a-boolean-parameter-c237a830b7a3 which goes in depth about this. What would be the problem of having the two lower-level functions that evaluate is then composed of (especially given none of them are exposed)?

I guess e2e tests can be added once we move from deprecation to error, right? What do you think?

For what kind of interactions would you like to see e2e tests? Not arguing against it at all, just wondering since I'm personally not very familiar with writing e2e tests.

@luisherranz
Copy link
Member

given that the resolveFunctions option would significantly change what the function does and give it different code paths. Boolean arguments like this tend to increase complexity
[...]
What would be the problem of having the two lower-level functions that evaluate is then composed of (especially given none of them are exposed)?

For me, it's fine if we increase the complexity in the runtime of the Interactivity API to simplify the external APIs that the extenders will use. Keep in mind that, although it is still far off in the roadmap, the idea is to let the extenders create their own directives someday. So for them, in my opinion, it will be more complex to use multiple functions than to pass options to a single function, especially if we keep adding more options in the future.

@luisherranz
Copy link
Member

For what kind of interactions would you like to see e2e tests? Not arguing against it at all, just wondering since I'm personally not very familiar with writing e2e tests.

Basically, a test that verifies that if one of the synchronous functions is executed without the wrapper, the deprecation is shown correctly. We can leave it for the next phase, anyway.

@felixarntz
Copy link
Member Author

@luisherranz

For me, it's fine if we increase the complexity in the runtime of the Interactivity API to simplify the external APIs that the extenders will use. Keep in mind that, although it is still far off in the roadmap, the idea is to let the extenders create their own directives someday. So for them, in my opinion, it will be more complex to use multiple functions than to pass options to a single function, especially if we keep adding more options in the future.

I think at this point this is mostly a theoretical concern. At the moment, none of these functions is externally available, so introducing a boolean flag would only increase complexity. So for that reason I don't think it makes sense to change it now, and we could revisit this again if and once we make those functions externally available. The way it currently works in this PR could easily be adjusted later if we decide to.

Basically, a test that verifies that if one of the synchronous functions is executed without the wrapper, the deprecation is shown correctly. We can leave it for the next phase, anyway.

👍 Makes sense, thanks!

@luisherranz
Copy link
Member

At the moment, none of these functions is externally available, so introducing a boolean flag would only increase complexity. So for that reason I don't think it makes sense to change it now, and we could revisit this again if and once we make those functions externally available. The way it currently works in this PR could easily be adjusted later if we decide to.

I don't like the idea of leaving things in a state that will require changes later. The fewer adjustments we have to make once this is public, the better.

It's also important that we, the WordPress Core contributors working on the directives, be the first to use these functions in what we believe is their final form, so we can see how well they fit and how easy they are to use. Before these functions go public, we might discover that relying on a single function with an options object isn’t the best approach after all—but we’ll only know by trying it out ourselves.

@felixarntz
Copy link
Member Author

I don't like the idea of leaving things in a state that will require changes later. The fewer adjustments we have to make once this is public, the better.

Fair point, let's try to figure it out now. You mentioned before:

So for them, in my opinion, it will be more complex to use multiple functions than to pass options to a single function, especially if we keep adding more options in the future.

Going back to my previous point: Using boolean flags increases the complexity of the code. This also impacts those that use the function(s). Today, if you see the evaluate function, you know what it does. If we make its behavior conditional on a boolean flag, you'll have to look more closely, since evaluate no longer serves one clear purpose. So IMO having separate functions for separate purposes helps also users of the eventually external function(s).

But let's take a step back first, I have a follow up questions to your idea from #68097 (review): How would resolveFunctions change what evaluate does? I don't fully understand how you intend it to be used, probably just a terminology thing to clarify. In this PR I implemented resolveEntry and evaluateResolved, both of which (in that order) make up the existing evaluate function. If I call evaluate( { resolveFunctions: false } ), which of the above two inner functions should be executed?

@luisherranz
Copy link
Member

If I call evaluate( { resolveFunctions: false } ), which of the above two inner functions should be executed?

What do you mean? This is trivial to implement on top of the original function: 3dc8989

@felixarntz
Copy link
Member Author

If I call evaluate( { resolveFunctions: false } ), which of the above two inner functions should be executed?

What do you mean? This is trivial to implement on top of the original function: 3dc8989

Your code clarifies what I was asking before: Before you were using the name resolveFunctions, so that's what was unclear in what you meant for it to do. The name you used now invokeFunctions makes more sense to me, so now I see what you mean. 👍

Regarding 3dc8989, I think this could work in principle, but I see two problems with it that the current PR implementation and trunk don't have:

  • You're getting rid of ...args which is currently being passed to the function. That would cause problems because e.g. event would no longer be able to be passed through to the callback, which would still need to happen if options.invokeFunctions is true.
  • In case the resolved entry is a function, your code ignores the negation operator that may be present. This is problematic because then the caller of evaluate( entry, { invokeFunctions: false } ) has no idea whether after calling the returned function themselves is needs to be negated or not.

To be clear, I'm not fundamentally opposed to your suggestion, but these two drawbacks would need to be addressed to make it beneficial over the current implementation.

@luisherranz
Copy link
Member

luisherranz commented Feb 13, 2025

so that's what was unclear in what you meant for it to do. The name you used now invokeFunctions makes more sense to me, so now I see what you mean. 👍

Sorry! My bad. I'm glad it's clear now 🙂

You're getting rid of ...args which is currently being passed to the function. That would cause problems because e.g. event would no longer be able to be passed through to the callback, which would still need to happen if options.invokeFunctions is true.

See this comment.

But... I've been thinking, and I think we should make things right and stop invoking functions in evaluate. It's not only not needed, but actually an antipattern. We can take this opportunity to make things right and actually educate people about how to correctly use the Interactivity API.

A bit of context: I should have made evaluate not invoke functions by default when we made the change to the new Store API. It's basically a leftover, because before the new Store API, the derived state was done with functions (selectors) instead of getters:

// Old Store API
store({
  myPlugin: {
    state: {
      someNumber: 1,
    },
    selectors: {
      sumNumbers: ({ state, context }) => {
        return state.someNumber + context.someNumber;
      },
    },
    actions: {
      someAction: (store) => {
        const { selectors } = store;
        selectors.sumNumbers(store);
      },
    },
  },
});

// New Store API
const { state } = store("myPlugin", {
  state: {
    someNumber: 1,
    get sumNumbers(): number {
      const { someNumber } = getContext();
      return state.someNumber + someNumber;
    },
  },
  actions: {
    someAction: () => {
      state.sumNumbers;
  },
});
// With old Store API
<span data-wp-text="myPlugin.selectors.sumNumbers"></span>

// With new Store API
<span data-wp-text="state.sumNumbers"></span>

With the new Store API, there's no need for all the directives that need a value, like data-wp-text, data-wp-class, data-wp-bind, etc, to invoke functions. Actually, it means people are doing it wrong, because they are still using some sort of selectors instead of proper derived state with getters. The only directives that need to invoke functions are the ones that are supposed to receive a callback, like data-wp-bind, data-wp-init, data-wp-watch, etc, and they can easily do so by invoking the callback themselves:

const cb = evaluate( entry );
cb( ...args ); // Pass whatever args they need.

The problem is that, from time to time, people who still don't know how to use the Interactivity API well yet, use functions instead of getters, and as they work fine, they keep using them (for example).

So, to make this 100% right, I'm thinking now that we should follow the same strategy used here for async actions and actually deprecate the fact that directives that are meant to receive a value invoke functions at all.

Of course, that is out of scope for this PR, but maybe we can take the first steps here? It should be something as simple as this: f0525ed (not tested)

Let me know what you think 🙂

@felixarntz
Copy link
Member Author

But... I've been thinking, and I think we should make things right and stop invoking functions in evaluate. It's not only not needed, but actually an antipattern. We can take this opportunity to make things right and actually educate people about how to correctly use the Interactivity API.

A bit of context: I should have made evaluate not invoke functions by default when we made the change to the new Store API. It's basically a leftover, because before the new Store API, the derived state was done with functions (selectors) instead of getters:

[...]

So, to make this 100% right, I'm thinking now that we should follow the same strategy used here for async actions and actually deprecate the fact that directives that are meant to receive a value invoke functions at all.

That makes a lot of sense now. Thanks for explaining in more detail!

Of course, that is out of scope for this PR, but maybe we can take the first steps here? It should be something as simple as this: f0525ed (not tested)

This looks good to me for the most part. The only thing I find a bit confusing for evaluate() is that the negation operator is only used if it's not a function. It makes sense based on what you're saying, that functions should only be used for actions and callbacks, so they would never have a return value and therefore it would be irrelevant to use a negation operator on a function. But today, someone might still do something like data-wp-show="! actions.getHiddenState()". Obviously that's bad for many reasons, and I don't anticipate for this to be common. But how should we deal with it?

I think the most straightforward solution for this PR is to continue invoking functions in evaluate() only if there is also a negation operator present. In this case, we can be sure that this is used in a directive other than data-wp-on..., data-wp-watch... etc., since those would never expect a return value. And when that happens, we should trigger a deprecation warning, to indicate that this will no longer work in the near future.

In other words there would be three possible scenarios for an entry for evaluate():

  • Entry is not for a function --> handle like today
  • Entry is for a function with negation operator --> handle like today, but trigger deprecation warnings
  • Entry is for a function without negation operator --> return function without calling it

By doing that, we don't (immediately) break the pattern of ! function() that someone may be using, but we warn about it, and we would have a plan to remove support for it.

Then we can have a separate PR for a later Gutenberg release for WordPress 6.9 that removes ...args from the evaluate() function entirely and removes support for the negation operator if it is provided in combination with a function.

@felixarntz
Copy link
Member Author

felixarntz commented Feb 13, 2025

@luisherranz I reverted the addition of the two new lower-level functions and instead implemented in 0dbd15f the change of no longer invoking functions in evaluate(), except for backward compatibility in case a negation operator is present (which will now trigger a deprecation warning).

This is similar to the example code you provided in f0525ed, just with a few additions to prevent errors, e.g. we have to check for whether the evaluate() return value is a function before calling it.

Having these checks everywhere is a bit annoying in terms of our codebase, so ideally we can improve this in the future. One of the reasons for those checks is that evaluate() is generic and returns any, so maybe we would want to introduce two wrappers for it, one that guarantees to return a function and another that guarantees to return anything but a function. Then the correct variant could be used in each directive as appropriate. It might also make the API easier to use externally. But as you said before, for now this would be mostly about cleaning up our own code, so we can probably do that in a separate PR.

The overall behavior remains the same as before, it's just a different implementation under the hood.

Update: It looks like something in this change is causing all sorts of e2e tests for the Interactivity API to fail. Maybe you find something in my implementation that's off and can easily be fixed. But this makes me think that it may be better to not change what evaluate() does as part of this PR since it kind of moves into separate territory anyway and is not needed for the goal of this PR. Exploring that in a separate PR might make more sense and would unblock this PR. If we needed to update all kinds of e2e tests unrelated to synchronous event callbacks in this PR, that would feel out of place. But please let me know if you find something in my last 2 commits that could be a cause for all these failing tests. 🤔

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. [Packages] Interactivity /packages/interactivity [Type] Performance Related to performance efforts
Projects
Status: 🔎 Needs Review
Development

Successfully merging this pull request may close these issues.

5 participants