-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Interactivity API: Add server derived state getter handling #6394
Interactivity API: Add server derived state getter handling #6394
Conversation
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
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 Core Committers: Use this line as a base for the props when committing in SVN:
To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Thanks for working on this, Jon 🙂 I think function closures is the way to go! We had them implemented exactly as you've done here before, but we removed them to have more time to evaluate them before its public release. However, what about mimicking the client store and, instead of this: wp_interactivity_state( 'myPlugin', array(
'list' => array(1, 2, 3),
'text' => __('this is'),
'itemText' => function( $store ) {
return $store['state']['text'] . ': ' . $store['context']['item'];
}
)); Do this: $state = wp_interactivity_state( 'myPlugin', array(
'list' => array(1, 2, 3),
'text' => __('this is'),
'itemText' => function() use ($state) {
$context = wp_interactivity_get_context( 'myPlugin' );
return $state['text'] . ': ' . $context['item'];
}
)); That way, it'd work with the "same API" in the client and in the server. What do you think? |
I think that would be nice. 😄 Just a note: for both approaches, if you want to read from a getter, I guess you would have to execute it, right? And it's the same for both approaches, with the difference that, for the second case, you don't have to pass any arguments. Following the example above, let's imagine that Approach 1 wp_interactivity_state( 'myPlugin', array(
'list' => array(1, 2, 3),
'text' => function ( $store ) {
return __( $store['context']['text'] );
},
'itemText' => function ( $store ) {
return $store['state']['text']( $store ) . ': ' . $store['context']['item'];
}
)); Approach 2 $state = wp_interactivity_state( 'myPlugin', array(
'list' => array(1, 2, 3),
'text' => function () {
$context = wp_interactivity_get_context( 'myPlugin' );
return __( $store['context']['text'] );
},
'itemText' => function () use ( $state ) {
$context = wp_interactivity_get_context( 'myPlugin' );
return $state['text']() . ': ' . $context['item'];
}
)); I see a benefit with the second approach: you don't need a reference to the |
I don't think it's viable to use a closure over the Consider the following: $s1 = wp_interactivity_state( 'ns', array( 'x'=>'y' ) );
$s2 = wp_interactivity_state( 'ns', array( 'z'=>'2' ) );
var_dump($s1, $s2, $s1 === $s2); This prints:
The state changes and If we were to use closures, we'd probably have to resort to some ugly reference passing that I don't think is a good idea. |
What about this? wp_interactivity_state( 'myPlugin', array(
'list' => array(1, 2, 3),
'text' => __('this is'),
'itemText' => function() {
$state = wp_interactivity_state( 'myPlugin' );
$context = wp_interactivity_get_context( 'myPlugin' );
return $state['text'] . ': ' . $context['item'];
}
)); |
That seems like it would work, but what's the advantage? Is there a drawback to passing in the state and context as parameters? We'd have to implement the context function which doesn't exist right now as far as I know. That function would also only make sense when called in one of these functions while processing directives, right? |
src/wp-includes/interactivity-api/class-wp-interactivity-api.php
Outdated
Show resolved
Hide resolved
I see a few drawbacks/inconsistencies with the old syntax.
I agree that using functions to get the state and context feels boilerplaty (especially with WordPress' long function names), but it would be the way to make it more consistent with the client experience. By the way, as those server "getters" are synchronous, maybe we can populate the default namespace and omit it, like we do in the client: wp_interactivity_state( 'myPlugin', array(
'list' => array(1, 2, 3),
'text' => __('this is'),
'itemText' => function() {
$state = wp_interactivity_state();
$context = wp_interactivity_get_context();
return $state['text'] . ': ' . $context['item'];
}
)); |
A couple of notes after playing a bit with this PR, trying to follow @luisherranz's approach:
To solve this, I guess we need to move the context and namespace stacks to class properties. @sirreal, do you agree with this approach? Any concerns or different ideas? PS: if we do this, I imagine that would require updating a bunch of PHPUnit tests... 😬 |
Yes, I think that makes sense because those global functions need to access those things. |
* @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags. | ||
*/ | ||
private function process_directives_args( string $html, array &$context_stack, array &$namespace_stack ) { | ||
private function process_directives_args( string $html ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I decided to keep this method separated as an internal function that can be called recursively (e.g., inside data_wp_each_processor
). However, I guess the name process_directives_args
is no longer appropriate.
Do you have new name ideas? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the name wrong now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not that it's wrong now, haha. But maybe it is? 😄 I just thought that the _args
part in the name referred to the context and namespace stacks being passed as args, so it was no longer relevant.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I see. So theres process_directives
(public function) which calls process_directives_args
, and that suffix doesn't make sense.
Yes, I think it could be updated, it's private anyways. Maybe it could just be _process_directives
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 2215c9a.
PS: I tried to come up with a more descriptive name (with no success). 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work @DAreRodz, I left some suggestions and questions, but I don't see any major issues right now.
public function state( string $store_namespace = '', array $state = array() ): array { | ||
if ( ! $store_namespace ) { | ||
$store_namespace = end( $this->namespace_stack ); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was a function with a required argument and an optional argument. Now, I think it's more complicated. It's a function with one behavior with 0-1 arguments, and with 2 arguments it's different. For example, state( '', [ 'foo' => 'bar' ] )
is wrong, right? Setting state requires an explicit namespace:
state()
=> lookupstate( 'ns' )
=> lookupstate( 'ns', ['x'=>'y'] )
=> set + lookup-
state( '', ['x'=>'y'] )
=> ⛔️ wrong!
We should never be calling this like state( '', [ 'foo' => 'bar' ] )
, right?
public function state( string $store_namespace = '', array $state = array() ): array { | |
if ( ! $store_namespace ) { | |
$store_namespace = end( $this->namespace_stack ); | |
} | |
public function state( ?string $store_namespace = null, ?array $state = null ): array { | |
if ( ! $store_namespace ) { | |
if ( $state ) { | |
// doing it wrong? shouldn't have falsy ns + state setting | |
} | |
if ( null !== $store_namespace ) { | |
// doing it wrong? Empty string is not allowed. | |
} | |
$store_namespace = end( $this->namespace_stack ); | |
} |
It also seems like null
is a better default value, we don't need to create the array and it should work well with the is_array
check later. It seems like the array_replace_recursive
call with empty array wouldn't do anything, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think that's better behavior. I'll update the implementation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 93acfc0.
* | ||
* @param string $store_namespace Optional. The unique store namespace identifier. | ||
*/ | ||
public function get_context( string $store_namespace = '' ): array { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems preferable to me to use null
to indicate "doesn't exist" instead of empty string. We could also add "doing it wrong" messages here if empty string is passed.
public function get_context( string $store_namespace = '' ): array { | |
public function get_context( ?string $store_namespace = null ): array { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 42e3e62.
* @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags. | ||
*/ | ||
private function process_directives_args( string $html, array &$context_stack, array &$namespace_stack ) { | ||
private function process_directives_args( string $html ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the name wrong now?
src/wp-includes/interactivity-api/class-wp-interactivity-api.php
Outdated
Show resolved
Hide resolved
@@ -396,6 +459,10 @@ private function evaluate( $directive_value, string $default_namespace, $context | |||
} | |||
} | |||
|
|||
if ( $current instanceof Closure ) { | |||
$current = $current(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like an easy place to break things by passing a closure that throws. Should we wrap this in try/catch with doing it wrong?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 3f571c0.
* @param string $store_namespace Optional. The unique store namespace identifier. | ||
* @return array The context for the specified store namespace. | ||
*/ | ||
function wp_interactivity_get_context( string $store_namespace = '' ): array { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I recommended this inside the class.
The wp_interactivity_state
function above also needs to be updated to match the class method.
* @param string $store_namespace Optional. The unique store namespace identifier. | |
* @return array The context for the specified store namespace. | |
*/ | |
function wp_interactivity_get_context( string $store_namespace = '' ): array { | |
* @param string|null $store_namespace Optional. The unique store namespace identifier. | |
* @return array The context for the specified store namespace. | |
*/ | |
function wp_interactivity_get_context( ?string $store_namespace ): array { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That looks better. Thanks. 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 48ebafd.
$state = $this->interactivity->state(); | ||
$context = $this->interactivity->get_context(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have any tests using the global functions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't. I guess I could use them here, but I'd have to modify the global WP_Interactivity_API
instance, which is the one used by the global functions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in a1fb279.
src/wp-includes/interactivity-api/class-wp-interactivity-api.php
Outdated
Show resolved
Hide resolved
src/wp-includes/interactivity-api/class-wp-interactivity-api.php
Outdated
Show resolved
Hide resolved
* @covers ::state | ||
*/ | ||
public function test_state_without_namespace() { | ||
$interactivity = new ReflectionClass( $this->interactivity ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be possible to rewrite the tests to avoid using reflections and exposing all the internals? It's very difficult to say how it all works just by looking at the test. One of the benefits of having code coverage is also documenting how the API can be used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, this is difficult to follow and seems to rely a lot on internals.
This sounds like a big refactor (maybe it could be pulled out to its own PR landed before this) but could we do something where we reset the global Interactivity API between tests in setup or teardown methods? Maybe that could use Reflection if absolutely necessary, but at least it should be isolate to one place.
I'd really like to see tests setting declaring their own state. I think the tests would become easier to read for the most part and it would make it easier to understand when there are multiple namespaces in play.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've been thinking about approaching this refactor and don't see an easy fix. A thing that comes up would be to inject the namespace and context stacks somehow, maybe during the WP_Interactivity_API
instantiation. Or maybe pass them as arguments to the evaluate
method, even though they are private properties.
Another thing would be if it makes sense to test private methods in unit tests. Maybe we need to rethink the way tests are written. 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm OK leaving this for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd really like to see tests setting declaring their own state. I think the tests would become easier to read for the most part and it would make it easier to understand when there are multiple namespaces in play.
@gziolo, @sirreal, I've created two helper methods to define the internal namespace and context stacks and moved the relevant state to each test case. At least the test code is easier to understand and maintain this way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That seems much better, thanks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the work on this @DAreRodz! A lot of this looks very nice, I've left a few comments and questions and I'd like to know your thoughts.
*/ | ||
public function test_evaluate_derived_state() { | ||
$result = $this->evaluate( 'state.derived' ); | ||
$this->assertEquals( "Derived state: myPlugin-state\nDerived context: myPlugin-context", $result ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a preference to use assertSame
where possible (strict equals vs. loose equals):
$this->assertEquals( "Derived state: myPlugin-state\nDerived context: myPlugin-context", $result ); | |
$this->assertSame( "Derived state: myPlugin-state\nDerived context: myPlugin-context", $result ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 3cf4236.
*/ | ||
public function test_evaluate_derived_state_that_throws() { | ||
$result = $this->evaluate( 'state.derivedThatThrows' ); | ||
$this->assertEquals( null, $result ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$this->assertEquals( null, $result ); | |
$this->assertNull( $result ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 3cf4236.
* @covers ::state | ||
*/ | ||
public function test_state_without_namespace() { | ||
$interactivity = new ReflectionClass( $this->interactivity ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, this is difficult to follow and seems to rely a lot on internals.
This sounds like a big refactor (maybe it could be pulled out to its own PR landed before this) but could we do something where we reset the global Interactivity API between tests in setup or teardown methods? Maybe that could use Reflection if absolutely necessary, but at least it should be isolate to one place.
I'd really like to see tests setting declaring their own state. I think the tests would become easier to read for the most part and it would make it easier to understand when there are multiple namespaces in play.
$context_stack = array(); | ||
$namespace_stack = array(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One thing that I wonder about, at this top level these values always started empty at this level. I wonder if the properties should also be cleaned here. Are they ever reset to an empty array? Could we get into a state where we expect these to be empty (to start processing) but a previous call left them "dirty" with data from the last process call?
I am having some second thoughts about moving these to a class property because they are really scoped to this function. One alternative might be to pass them by reference. I think the result would be similar but they'd be clearly scoped to this function instead of class properties that only make sense at a particular time in the class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am having some second thoughts about moving these to a class property because they are really scoped to this function. One alternative might be to pass them by reference. I think the result would be similar but they'd be clearly scoped to this function instead of class properties that only make sense at a particular time in the class.
That's how it worked previously, right?
To be honest, I completely agree it would be a better approach. The main issue is how to make that temporary state available through a class method, i.e., wp_interactivity()->get_context()
. That's why I decided to use class properties.
Would it make sense to preserve all the previous logic but store some internal references to the locally instantiated context & namespace stacks so we can use them inside get_context()
? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main issue is how to make that temporary state available through a class method, i.e.,
wp_interactivity()->get_context()
Right 😅 I forgot about this essential bit. Let's keep the class properties.
What if these stacks were created when this function runs and nulled when the function returns so they're unusable outside of directive processing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@DAreRodz What do you think about this part of the question?
One thing that I wonder about, at this top level these values always started empty at this level. I wonder if the properties should also be cleaned here. Are they ever reset to an empty array? Could we get into a state where we expect these to be empty (to start processing) but a previous call left them "dirty" with data from the last process call?
Should these properties should be nullable? They'd be set to arrays during directive processing and set back to null when processing is complete. That would give us a nice way to show _doing_it_wrong
messages when using functions the wrong way or outside of processing, it would be a null
check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It makes sense, as this method is synchronously executed, and there is no opportunity to call it again in the middle.
Done in 670022b. I've also added a _doing_it_wrong
message for state()
and get_context()
when they are called outside of a process_directives
call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work @DAreRodz, thanks for working through all the details. I can't approve since it's "my" PR, but I think this is in a good place ✅
(I left two small suggestions to avoid "after" language in a comment when we really mean "before or after").
src/wp-includes/interactivity-api/class-wp-interactivity-api.php
Outdated
Show resolved
Hide resolved
src/wp-includes/interactivity-api/class-wp-interactivity-api.php
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've only had the chance to take a quick look at it, but the code seems fine to me. I approve it on behalf of Jon 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR looks great and would be awesome to add it this release.
02c9ff0
to
69aa352
Compare
Co-authored-by: Jon Surrell <[email protected]>
Co-authored-by: Jon Surrell <[email protected]>
Co-authored-by: Jon Surrell <[email protected]>
Co-authored-by: Jon Surrell <[email protected]>
Co-authored-by: Jon Surrell <[email protected]>
69aa352
to
a0f0e9e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the rebase, @sirreal! 👏
Committed with https://core.trac.wordpress.org/changeset/58327. |
Implement derived state callback handling in server directive processing.
The implementation expects a
Closure
type.call_user_func
was avoided because it would be difficult to differentiate the string paths in the directive from a callable function name.Trac ticket: https://core.trac.wordpress.org/ticket/61037
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.