Repository navigation
Fix a fatal error when binding non-scalar values, and static analysis issues, in WP_Interactivity_API - #12725
westonruter wants to merge 40 commits into
Conversation
…ix() returns list<non-empty-string>|null
| /* | ||
| * Only scalar values can be stored in an attribute value. Strings and booleans are passed in as-is. | ||
| * Numbers are cast to strings. Everything else is rejected as a usage error. | ||
| */ | ||
| if ( null !== $result ) { | ||
| if ( ! is_scalar( $result ) ) { | ||
| _doing_it_wrong( | ||
| __METHOD__, | ||
| sprintf( | ||
| /* translators: %s: The attribute name. */ | ||
| __( 'Attempted to store non-scalar value the "%s" attribute.' ), | ||
| esc_html( $entry['suffix'] ) | ||
| ), | ||
| '7.1.0' | ||
| ); | ||
| $result = null; | ||
| } elseif ( is_int( $result ) || is_float( $result ) ) { | ||
| $result = (string) $result; | ||
| } | ||
| } |
There was a problem hiding this comment.
my only immediate response is questioning if passing numbers here is common, and whether we should call _doing_it_wrong() in these cases, if we allow them at all.
I’m reminded of some stringifying issues with CSS numbers, whereas the conversion is locale-dependent.
that is, would we want to use false|null, true, and string values only and check is_string()?
There was a problem hiding this comment.
My intention was to not cause anything to fail which previously passed through. There was already the case for \Tests_WP_Interactivity_API_WP_Bind::test_wp_bind_sets_number_value() for an int value, ensuring it is implicitly coerced to string. There just wasn't the same for a float.
And now in e6d704c it also handles JsonSerializable.
There was a problem hiding this comment.
In regards to floats, it's news to me that PHP would use the locale to when coercing floats to strings. However, this behavior was eliminated in PHP 8 so the JSON-compatible representation is always used: https://php.watch/versions/8.0/float-to-string-locale-independent
But I've addressed it in b292380 by using wp_json_encode() to convert an int and a float to a string. This bypasses locale-specific formatting.
In the course of doing that, I discovered that INF and NAN are additional scalar values in PHP which cannot be expressed in JSON. Fixed in 1791d03 and d15a50e.
My goal here is to make sure that there is no back-compat breakage, where int and float previously worked.
There was a problem hiding this comment.
locale-dependent float conversion bit some CSS processing because it creating invalid units, e.g. 3,45px
I would prefer @luisherranz or someone reviews this section. it’s the kind of code that looks right, but perhaps only because of my unfamiliarity with the nuance of the domain.
in some ways I would like to see this as a separate function, for example, WP_Interactivity_API::serialize_for_attribute( mixed $value ), and we could unit-test this and show behaviors and have a clear behavior to assert.
on the other hand it’s not too big and that could make things even more removed from the flow of logic.
the ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] ) checks are not helping me understand intent here.
There was a problem hiding this comment.
for reference, here is the CSS locale issue
There was a problem hiding this comment.
json_encode() is not locale-dependent, unlike (float) in PHP 7.4. So that's why the float value isn't cast to a string.
See this example in PHP versions including 7.4: https://3v4l.org/jdDsn#v
Interesting that PHP 5.2 outputs the comma as delimiter! (But we don't care anymore.)
There was a problem hiding this comment.
the
( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )checks are not helping me understand intent here.
We are just matching Preact's detection of aria- and data- attributes so that the server-side rendered HTML matches exactly the client-side Preact's hydrated HTML.
Probably not the best code on Preact's side as any non-standard or future xxxx- attributes also match, but we chose to follow 100% what they do to avoid SSR issues.
I would prefer @luisherranz or someone reviews this section. it’s the kind of code that looks right, but perhaps only because of my unfamiliarity with the nuance of the domain.
Looks good to me! 👍
There was a problem hiding this comment.
Pull request overview
This PR tightens up types and edge-case handling in the Interactivity API’s directive parsing/collection path and hardens data_wp_bind_processor() against non-string attribute values, addressing static analysis concerns around nullable returns and value types.
Changes:
- Adds PHPStan/typed-property annotations for directive processor maps and directive parsing/entry extraction return shapes.
- Makes directive parsing/entry extraction more defensive (e.g., handling empty substrings,
nullattribute lists/values, invalid directive names). - Ensures
data-wp-bindonly writes scalar attribute values (casting numbers to strings and rejecting non-scalars with_doing_it_wrong()).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/wp-includes/interactivity-api/class-wp-interactivity-api.php | Adds stricter typing and defensive checks for directive parsing/entry extraction; hardens data-wp-bind attribute writes against non-scalar values. |
| src/wp-includes/html-api/class-wp-html-tag-processor.php | Adds PHPStan annotations clarifying attribute map and get_attribute_names_with_prefix() return types. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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. |
Covers the new guard in data_wp_bind_processor() which rejects non-scalar evaluation results instead of passing them along to set_attribute(), where they previously caused a TypeError. A data provider exercises lists, associative arrays, empty arrays, plain objects, and a stringable object, asserting for each that no attribute is set, that a pre-existing attribute is removed, and that _doing_it_wrong() is triggered. The stringable object is included deliberately to document that the check is is_scalar(), not "castable to string". Also adds coverage for casting a float value to a string, and types the return of the test's process_directives() helper so that the tag processor is no longer inferred as mixed throughout the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The data provider for non-scalar values now yields the cross product of each value with a single bound attribute, so that the two escaping paths in WP_HTML_Tag_Processor::set_attribute() are exercised independently: ordinary attributes such as id go through strtr(), while URI attributes such as href go through esc_url(). Binding both attributes in the same markup meant the ordinary attribute failed first and masked the URI one. Each test also asserts the exact message recorded for _doing_it_wrong(), rather than only that it was triggered for the method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The message was missing a word and did not say what to do about it. It now names binding as the operation and points at the fix, which is to cast the value where it is stored rather than at the point of binding. Also documents why an object is rejected even when it defines __toString(): PHP would coerce it for the string parameters of the escaping functions, but the same reference is serialized as JSON for the client, where the string representation is unavailable, so the server and client would disagree once the directive is evaluated during hydration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| private function parse_directive_name( string $directive_name ): ?array { | ||
| // Remove the first 8 characters (assumes "data-wp-" prefix) | ||
| $name = substr( $directive_name, 8 ); | ||
| $name = (string) substr( $directive_name, 8 ); |
There was a problem hiding this comment.
noting that pre-PHP-8.0 this would have returned false if the directive name was shorter than 8 bytes.
👍
The client store is built by JSON-encoding the state, which calls jsonSerialize(). Resolving the bound value through the same method means the attribute rendered on the server and the value the client hydrates with cannot disagree, so such an object no longer needs rejecting. This composes with the handling already in place: a serialized number goes through the numeric cast, a boolean keeps the boolean attribute semantics, and an array or object still reports incorrect usage. The resolution happens before the null check so that serializing to null removes the attribute rather than reporting incorrect usage. Two cases are fixed. An object implementing only JsonSerializable, whose serialized form the client was already receiving correctly, previously raised a TypeError in the escaping functions because PHP cannot coerce it to string. An object implementing both JsonSerializable and __toString with matching forms had worked before the non-scalar check was added, and works again. An object defining only __toString() is still rejected, now for the right reason: __toString() is never consulted, so the value the client receives is what decides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
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. |
| $suffix_index = strpos( $name, '--' ); | ||
|
|
||
| if ( false === $suffix_index ) { | ||
| if ( false === $suffix_index || 0 === $suffix_index ) { |
There was a problem hiding this comment.
The inclusion of 0 === $suffix_index here is to guarantee that when this method never returns an empty string for the prefix. This seems like it should never happen.
There was a problem hiding this comment.
I would like to speak on this but I don’t know what it means to have data-wp---enable--bind. the JavaScript .dataset name is wp--Enable-Bind for that. I suppose that -Enable would not be a valid directive name, or if the Interactivity API explicitly forbids interacting with attributes via .dataset.
@luisherranz is there a syntax specification for directives and a relationship between the attribute names and the JavaScript names?
There was a problem hiding this comment.
Yes, the syntax is data-wp-[prefix]--[suffix]---[unique-id], being prefix required for all the directives, suffix required for some of them (including bind), and unique-id not supported on the ones that are unique per element (like data-wp-text).
Prefixes cannot start with -, so data-wp--bind or data-wp---bind are indeed not valid iAPI directives.
The extraction algorithm is roughly (saying it from memory):
- Prefix is everything from
data-wp-until two or more hyphens are found. - If three hyphens are found (not less, not more), everything after that is the
unique-id, and the number of hyphens inside it no longer matters. - If two or more hyphens are found, but never three, everything from there is the
suffixuntil three hyphens are found, in which case that's where theunique-idstarts.
That also has some implications, such as that the suffix always has to precede the unique-id, and that the suffix can start with two hyphens or more (but never one).
data-wp-style----var is a valid directive with prefix: style and suffix: --var.
There was a problem hiding this comment.
if ( false === $suffix_index || 0 === $suffix_index ) {
This is a bit unexpected to me , but it also is the behavior in trunk, which I just realized.
data-wp---foo is prefix: null, suffix: foo in trunk and now it will be prefix: ---foo, suffix: null. To me, neither of the two cases makes sense and this simply should not be a valid iAPI directive name.
It currently has no application, as there is no processor registered on either the server or the client for any directive with this syntax, and custom directives cannot be registered, so no BC issues and it could well be left for a follow-up post WP 7.1 syncing both the server and the client again on this.
|
|
||
| // Check for invalid characters (anything not a-z, 0-9, -, or _) | ||
| if ( preg_match( '/[^a-z0-9\-_]/i', $name ) ) { | ||
| if ( '' === $name || preg_match( '/[^a-z0-9\-_]/i', $name ) ) { |
There was a problem hiding this comment.
The '' === $name check here is to ensure that this never returns an empty string. Perhaps it would be better like this, though, as an allowlist for the entire string:
| if ( '' === $name || preg_match( '/[^a-z0-9\-_]/i', $name ) ) { | |
| if ( 1 !== preg_match( '/^[a-z0-9\-_]+$/i', $name ) ) { |
WP_Interactivity_API::data_wp_bind_processor()WP_Interactivity_API
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (7)
tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php:659
- Test expectation hard-codes the _doing_it_wrong() message. If the production message is updated to mention supported scalar types (string/boolean/number), this assertion needs to match.
'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)',
tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php:697
- Test expectation hard-codes the _doing_it_wrong() message. If the production message is updated to mention supported scalar types (string/boolean/number), this assertion needs to match.
'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)',
tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php:948
- Test expectation hard-codes the _doing_it_wrong() message. If the production message is updated to mention supported scalar types (string/boolean/number), this sprintf format string needs to match.
'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)',
tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php:984
- Test expectation hard-codes the _doing_it_wrong() message. If the production message is updated to mention supported scalar types (string/boolean/number), this sprintf format string needs to match.
'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)',
src/wp-includes/interactivity-api/class-wp-interactivity-api.php:1193
- This message recommends resolving to a string, but the bind processor supports booleans (including boolean attribute semantics) and finite numbers. Updating the message to mention the actual supported scalar types makes the guidance accurate for developers using data-wp-bind.
__( 'Attempted to bind INF or NAN to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string.' ),
src/wp-includes/interactivity-api/class-wp-interactivity-api.php:1161
- The _doing_it_wrong() message says the bound value must resolve to a string, but this processor explicitly accepts other scalar types (booleans are supported, and numbers are formatted via wp_json_encode()). The message should reflect the actual accepted types so it doesn’t mislead developers (e.g. when boolean attribute semantics are desired).
__( 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string.' ),
tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php:530
- Test expectation hard-codes the _doing_it_wrong() message. If the message is updated to reflect supported scalar types (string/boolean/finite number), this assertion needs to be kept in sync.
This issue also appears in the following locations of the same file:
- line 659
- line 697
- line 948
- line 984
'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind INF or NAN to the "data-ratio" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)',
| * three as well, so none is a regression. | ||
| */ | ||
| $encoded = wp_json_encode( $result ); | ||
| if ( false === $encoded ) { |
There was a problem hiding this comment.
this probably works by elimination, but the check is truly this:
| if ( false === $encoded ) { | |
| if ( JSON_ERROR_INF_OR_NAN === json_last_error() ) |
it’s possible that it would fail for other reasons, but this matches the _doing_it_wrong() message that plausibly, others wouldn’t.
…into fix/phpstan-interactivity-api-bind-processor
… false Co-authored-by: Dennis Snell <dmsnell@git.wordpress.org>
The suggestion in WordPress#12725 (comment) replaces `'---' !== $remaining ? (string) substr( $remaining, 3 ) : null` with `strlen( $remaining ) > 3 ? substr( $remaining, 3 ) : null`. It does read more clearly, but it does not pass PHPStan as written, for two separate reasons: 1. It drops the `(string)` cast. PHPStan analyses core against a PHP 7.4 floor (`phpVersion.min` in `tests/phpstan/base.neon`), and there `substr()` returns `false` for an offset past the end of the string. The expression therefore widens to `string|false`, which no longer satisfies the declared `unique_id: string|null`. The cast was not decoration; it collapsed that `false` into `''`. 2. `strlen( $remaining ) > 3` narrows nothing usable. PHPStan can turn `strlen( $s ) > 0` into `non-empty-string` for `$s` itself, but it does not carry a length comparison on `$remaining` over to the length of a substring derived from it. The result stays a plain `string`, so the shape cannot be tightened to `non-empty-string` either. Both are solved by the variable form floated in the same comment, combined with the `empty()` check the three sibling branches already use. Narrowing a variable is something PHPStan can follow, and `empty()` absorbs the PHP 7.4 `false` without needing a cast. That also removes an inconsistency. This branch was the only one testing for an empty result strictly, so `data-wp-x---0` yielded a unique ID of "0" while `data-wp-x--0` discarded its suffix. The client's `parseDirectiveName` maps both to null with `|| null`, and "0" is falsy in JavaScript as well, so discarding both keeps the two parsers in agreement. The prefix is unaffected: it is returned as-is on either side, and "0" is a legal prefix. The `@phpstan-return` shape now declares `non-falsy-string|null` for `suffix` and `unique_id`, which is what `empty()` guarantees and what PHPStan verifies, while `prefix` stays `non-empty-string`. New test cases cover a "0" prefix, suffix, and unique ID. Co-Authored-By: Dennis Snell <dmsnell@git.wordpress.org> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
src/wp-includes/interactivity-api/class-wp-interactivity-api.php:1199
- Same as above: the INF/NAN
_doing_it_wrong()message instructs that the value must resolve to a string, but the binding logic supports numbers and booleans too. If the intent is “must be a scalar value representable in the client store”, the message should reflect that to avoid misleading usage guidance (and tests asserting the exact string will need updating).
/* translators: %s: The attribute name. */
__( 'Attempted to bind INF or NAN to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string.' ),
esc_html( $entry['suffix'] )
),
src/wp-includes/interactivity-api/class-wp-interactivity-api.php:783
- The docblock claims the client’s
|| nullwill discard the string "0" because it’s “falsy in JavaScript”, but "0" (a non-empty string) is truthy in JavaScript. This comment is misleading for anyone trying to reason about server/client parity; consider rewording it to describe the actual PHP behavior (this method usesempty(), which also treats "0" as empty) and/or revisit whether dropping "0" is intended.
* A falsy suffix or unique ID is normalized to null. This includes the string "0", which the
* client's `|| null` likewise discards since "0" is falsy in JavaScript as well.
*
tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php:898
- This test comment states that the string "0" is falsy in JavaScript (and that
|| nullwould therefore map it to null), but non-empty strings are truthy in JavaScript. If the goal here is to document why PHP normalizes "0" to null, the comment should describe PHP’sempty()semantics instead (or otherwise avoid the incorrect JS rationale).
* Should discard a "0" suffix or unique ID the same way an empty one is discarded. The
* client's `parseDirectiveName` maps both to null with `|| null`, and "0" is falsy in
* JavaScript as well, so both sides agree. A "0" prefix is kept, since the prefix is
* returned as-is on both sides.
src/wp-includes/interactivity-api/class-wp-interactivity-api.php:1166
- The
_doing_it_wrong()message says the bound value must “resolve to a string”, butdata_wp_bind_processor()explicitly accepts booleans (boolean attributes / aria-/data- semantics) and numbers (which are JSON-formatted). This makes the guidance inaccurate for developers debugging the warning. Consider updating the message to say it must resolve to a scalar value (string/number/boolean) or similar; note the PHPUnit assertions in this PR will need updating too.
This issue also appears on line 1196 of the same file.
/* translators: %s: The attribute name. */
__( 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string.' ),
esc_html( $entry['suffix'] )
Corrects the reasoning in the previous commit, which claimed that the string "0" is falsy in JavaScript and that the client's `parseDirectiveName` therefore discards it with `|| null`. That is wrong: every non-empty string is truthy in JavaScript, so `'0' || null` evaluates to `'0'`. (`'0' == false` is true, but that is loose equality with numeric coercion, which `||` does not use.) Caught by Copilot in WordPress#12725 (review) Since the client keeps a "0" suffix or unique ID, switching all four checks to `empty()` made the server diverge from it in every case. The inconsistency the previous commit set out to fix was real, but it resolved it the wrong way: the strict check was the correct one, and it was the three `empty()` branches that were dropping a "0" the client keeps. All four now compare strictly against '', which preserves "0" and agrees with the client. The `(string)` casts stay: PHPStan analyses core against a PHP 7.4 floor where `substr()` can return `false`, and `'' === false` is false under strict comparison, so without the cast that `false` would leak past the check. The `@phpstan-return` shape goes back to `non-empty-string|null` for `suffix` and `unique_id`, which is what the strict comparison guarantees, and the docblock now warns against reintroducing `empty()` here. Tests assert that "0" survives as a prefix, as a suffix, and as a unique ID. Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both messages told the developer to ensure the value "resolves to a string", but `data_wp_bind_processor()` accepts booleans and numbers as well, so the guidance described a stricter contract than the code enforces. The INF/NAN message was the more misleading of the two, since INF and NAN are floats and floats are accepted. The problem there is not the type but the finiteness, so resolving to a string is not what is being asked for. Reported by Copilot in WordPress#12725 (review) The five assertions that match these messages verbatim are updated to suit. Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d value The message named two of the three values that reach it. The branch is entered whenever `wp_json_encode()` reports `JSON_ERROR_INF_OR_NAN`, which `-INF` produces as well, and `data_non_scalar_values()` already feeds `-INF` through it. So the suite proved the notice fires for a value the notice does not name. "Non-finite" is the property the branch actually tests, it covers all three without enumerating them, and it pairs with the "finite number" already named in the following sentence. Reported by Copilot in WordPress#12725 (review) Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
luisherranz
left a comment
There was a problem hiding this comment.
LGTM!
Just a minor follow-up that I'd do below.
| $suffix_index = strpos( $name, '--' ); | ||
|
|
||
| if ( false === $suffix_index ) { | ||
| if ( false === $suffix_index || 0 === $suffix_index ) { |
There was a problem hiding this comment.
if ( false === $suffix_index || 0 === $suffix_index ) {
This is a bit unexpected to me , but it also is the behavior in trunk, which I just realized.
data-wp---foo is prefix: null, suffix: foo in trunk and now it will be prefix: ---foo, suffix: null. To me, neither of the two cases makes sense and this simply should not be a valid iAPI directive name.
It currently has no application, as there is no processor registered on either the server or the client for any directive with this syntax, and custom directives cannot be registered, so no BC issues and it could well be left for a follow-up post WP 7.1 syncing both the server and the client again on this.
The directive syntax is `data-wp-[prefix]--[suffix]---[unique-id]`, and a prefix
cannot begin with a hyphen, so neither `data-wp--bind` nor `data-wp---foo` names
a directive. Both were being parsed rather than rejected, and neither reading is
meaningful: treating the leading hyphens as a suffix separator leaves the prefix
empty, while treating them as part of the prefix produces a prefix which cannot
be registered.
`parse_directive_name()` now returns null for both. The existing check for a
`--` at offset 0 already had to be made, since it is what proves the prefix is
never empty, so this only changes what that branch does with such a name; the
lone leading hyphen is caught alongside it, as `strpos()` finds no `--` there at
all. A suffix may still begin with hyphens, since only the prefix is
constrained: `data-wp-style----var` remains prefix `style`, suffix `--var`.
This has no effect on rendering. No processor is registered under a prefix
beginning with a hyphen, so the attribute was already ignored whichever way it
parsed, on the server and on the client alike. The client's `parseDirectiveName`
continues to split `data-wp---foo` into `{ prefix: '', suffix: 'foo' }` and
ignores it for the same reason, so the two stay observationally equivalent until
the client adopts the same rule.
Raised by @luisherranz in
WordPress#12725 (comment)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/wp-includes/interactivity-api/class-wp-interactivity-api.php:779
- This docblock line ends the URL with ".:", which appears to be a punctuation typo and reads oddly in generated docs.
* See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts.:
@luisherranz I think I got it. How about 0d8e023? |
The `WP_Interactivity_API::data_wp_bind_processor()` method passed the evaluated reference straight to `WP_HTML_Tag_Processor::set_attribute()`, which is typed `string|bool`, so a non-scalar value raised a `TypeError` inside the escaping functions: in `strtr()` for an ordinary attribute, and in `esc_url()` for a URI one. This is reachable from post content alone, with no plugin code, because `data-wp-context` can supply an array inline. Such a value is now rejected with `_doing_it_wrong()` and the attribute is left unset, as a `null` value already did, rather than taking down the whole page render. An object is resolved by round-tripping it through `wp_json_encode()` and `json_decode()`, which is how the client store itself is built, so the two cannot disagree about what it serializes to. `__toString()` is never consulted: an object whose string form differed from its JSON form used to render a value the client immediately overwrote, and one implementing only `JsonSerializable` fataled even though the store was already correct. Numbers are formatted by that same encoder rather than cast, which avoids both the locale-dependent decimal separator a float cast produces before PHP 8.0 and the rounding to `precision` where the store uses `serialize_precision`. `INF` and `NAN` are scalars but JSON can represent neither, and there the cost is the page's entire state rather than one attribute, so they are rejected with a message of their own. Parsing of directive attributes is hardened. The code touching the `::data_wp_bind_processor()` method is brought to PHPStan level 10 without ignores, including the use of more specific types where available. Developed in #12725. Follow-up to r57563, r61020, r62070. Props westonruter, dmsnell, luisherranz, darerodz. See #64898. Fixes #65740. git-svn-id: https://develop.svn.wordpress.org/trunk@62944 602fd350-edb4-49c9-b593-d223f7449a82
The `WP_Interactivity_API::data_wp_bind_processor()` method passed the evaluated reference straight to `WP_HTML_Tag_Processor::set_attribute()`, which is typed `string|bool`, so a non-scalar value raised a `TypeError` inside the escaping functions: in `strtr()` for an ordinary attribute, and in `esc_url()` for a URI one. This is reachable from post content alone, with no plugin code, because `data-wp-context` can supply an array inline. Such a value is now rejected with `_doing_it_wrong()` and the attribute is left unset, as a `null` value already did, rather than taking down the whole page render. An object is resolved by round-tripping it through `wp_json_encode()` and `json_decode()`, which is how the client store itself is built, so the two cannot disagree about what it serializes to. `__toString()` is never consulted: an object whose string form differed from its JSON form used to render a value the client immediately overwrote, and one implementing only `JsonSerializable` fataled even though the store was already correct. Numbers are formatted by that same encoder rather than cast, which avoids both the locale-dependent decimal separator a float cast produces before PHP 8.0 and the rounding to `precision` where the store uses `serialize_precision`. `INF` and `NAN` are scalars but JSON can represent neither, and there the cost is the page's entire state rather than one attribute, so they are rejected with a message of their own. Parsing of directive attributes is hardened. The code touching the `::data_wp_bind_processor()` method is brought to PHPStan level 10 without ignores, including the use of more specific types where available. Developed in WordPress/wordpress-develop#12725. Follow-up to r57563, r61020, r62070. Props westonruter, dmsnell, luisherranz, darerodz. See #64898. Fixes #65740. Built from https://develop.svn.wordpress.org/trunk@62944 git-svn-id: http://core.svn.wordpress.org/trunk@62188 1a063a9b-81f0-0310-95a4-ce76da25c4cd
|
|
||
| // Find the first occurrence of '--' to separate the prefix | ||
| // Find the first occurrence of '--' to separate the prefix. | ||
| $suffix_index = strpos( $name, '--' ); |
There was a problem hiding this comment.
this might be a possible follow-up, but if the rules are dominated by the appearance of unique ids and suffixes, then some of this might just be easier if we pick off those segments first.
$unique_id_at = strpos( $name, '---' );
$has_unique_id = false !== $unique_id_at;
$unique_id = $has_unique_id ? substr( $name, $unique_id_at + 3 ) : '';
$name = $has_unique_id ? substr( $name, 0, $unique_id_at ) : $name;
$suffix_at = strpos( $name, '--' );
$has_suffix = false !== $suffix_at;
$suffix = $has_suffix ? substr( $name, $suffix_at + 2 ) : '';
$name = $has_suffix ? substr( $name, 0, $suffix_at ) : $name;
$prefix = $name;this removes any possible conflation of those values by searching longest-first.
Looks great! I'll prepare the fix to sync it in Gutenberg, thank you very much! 🙂 |
|
Gutenberg PR for the syntax backport here: |


Static analysis work on
WP_Interactivity_API(Core-64898) surfaced a fatal error whendata-wp-bindbinds a non-scalar value to an HTML attribute (Core-65740). This fixes both, and closes the neighbouring gaps that fix brought to light.The fatal error
Any non-scalar value reaching
WP_HTML_Tag_Processor::set_attribute()hit aTypeError, because that method is typedstring|booland passes the value straight to the escaping functions. Both escaping paths failed, in different places:idTypeError: strtr(): Argument #1 ($string) must be of type string, array giveninWP_HTML_Tag_Processor::set_attribute()wp_kses_uri_attributes(), e.g.hrefTypeError: ltrim(): Argument #1 ($string) must be of type string, array given, from insideesc_url()This is reachable from post content alone, with no plugin code, because
data-wp-contextcan supply the array inline. Paste either of these into the post editor's code editor (⌥⌘M) to reproduce on trunk:The
core/accordionwrapper is needed only because directives are processed for blocks whosesupports.interactivityistrue; any of those works. The directives have to sit on a nested element, since the accordion's own render filter overwritesdata-wp-interactiveanddata-wp-contexton its wrapper.data_wp_bind_processor()now rejects such a value with_doing_it_wrong()and leaves the attribute unset (removing it if it was already present, consistent with how anullvalue is handled), rather than taking down the whole page render.Objects
The interesting case is objects, since the value bound on the server is also serialized into the client store and re-evaluated during hydration. Those two have to agree, and only the value the client receives can decide it — so an object is resolved by round-tripping it through
wp_json_encode()/json_decode(), which is exactly how that store is built. Agreement is then structural rather than checked.__toString()onlyid="the-string-form"{"prop":"prop value"}__toString()+jsonSerialize()agreeingid="the-string-form""the-string-form"id="the-string-form"__toString()+jsonSerialize()differingid="the-string-form"{"not":"the string"}JsonSerializable, no__toString()"the-string-form"id="the-string-form""the-inner-string"id="the-inner-string"Rows 1 and 3 rendered a value the client then overwrote with
[object Object], since PHP coerces__toString()for the escaping functions' string parameters but JSON has no equivalent. Row 4 fataled even though the client store was already correct, purely because PHP cannot coerce an object without__toString(). Row 5 is why the resolution round-trips rather than callingjsonSerialize()once: a single call stops after one level, whereas the encoder keeps going, so the server rejected a value the browser was about to accept.Resolving the object before the
nullcheck also means one serializing tonullremoves the attribute quietly, and the rest composes: a serialized number is formatted as below, a boolean keeps the boolean-attribute semantics.Note that
__toString()is never consulted. Row 3 is rejected because the object's own JSON opinion is what the browser will see.Numbers
Numbers are formatted by the same encoder rather than cast to string, which closes two more divergences from the client. Measured on PHP 7.4:
(string)wp_json_encode()String(v)1.5underLC_NUMERIC=de_DE1,51.51.51/30.333333333333330.33333333333333330.3333333333333333Casting a float is locale-dependent before PHP 8.0 (RFC), and 7.4 is the minimum supported version. The cast also rounds to
precisionwhere the store usesserialize_precision. Routing both through one function makes them agree whatever those settings are. Integers were never affected, as only floats consult the locale.Neither is a regression from the fix above — the cast is the same conversion
set_attribute()was already applying implicitly, one frame later — but both are worth closing while the line is being touched.INF and NAN
These are scalars, so they passed the check above, but JSON can represent neither.
wp_json_encode()on the store returnsfalse, andWP_Script_Modules::print_script_module_data()casts that failure to a string, so the client is served an empty<script type="application/json">in place of all of its state: every binding on the page loses its value, not only this one. The attribute meanwhile receivedINForNAN, which means nothing as an attribute value and does not match the client either, where the same numbers render asInfinityandNaN.They are now rejected too, with their own message, since describing them as non-scalar would be wrong. Rejecting the binding only draws attention to the problem; removing the value from the state is what resolves it.
Static analysis
The rest is PHPStan level 10 work on the same code path, which is how the missing type checks came to light:
WP_Interactivity_API::$directive_processorsgains an array shape whose values are the literal method names. Without it, the dynamic dispatch throughcall_user_func_array()is unresolvable, so all eightdata_wp_*_processor()methods were reported unused and the callable array was reported as not callable. Ten errors, no ignores.get_directive_entries()declares its return type and array shape, and now skips a directive name it cannot parse instead of destructuringnull. Theget_attribute()null check is defensive: the attribute names come fromget_attribute_names_with_prefix(), so it should not be reachable.parse_directive_name()documents its shape and guarantees a non-empty prefix. The character check became an anchored whitelist, and a name beginning with--no longer yields an empty prefix. Neither changes the outcome for a directive that was previously matched — an empty prefix matched no known directive either way — but the return type is now honest.extract_directive_value()documents its tuple shape.WP_HTML_Tag_Processor::$attributesandget_attribute_names_with_prefix()gain@phpstan-annotations only. No functional change.data_wp_bind_processor()gains avoidreturn type.Testing instructions
New coverage in
tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php. Non-scalar values are exercised as a cross product with the attribute bound, so thestrtr()andesc_url()escaping paths each fail on their own data set rather than one masking the other, and each asserts the exact_doing_it_wrong()message rather than only that it was triggered. There is also a test asserting that the rendered attribute value appears verbatim in the JSON sent to the client, which is the invariant the object handling rests on.The locale test skips itself where no comma-decimal locale is installed, so it will not fail a runner without one.
To confirm the tests would catch a regression, remove the
is_scalar()block fromdata_wp_bind_processor()and re-run: 32 errors and 8 failures across the 40 data sets.Verified against PHP 7.4 as well as 8.3, since the float formatting behaves differently between them.
Follow-ups, not addressed here
state()is the better place to catch a non-finite float. Binding is only where you happen to notice a store which is already broken, and one such value anywhere in the state discards the whole of it._process_directives()restores$context_stackand$namespace_stackonly on the unbalanced-tags path, not when an exception is thrown mid-traversal. A caught fatal therefore corrupts every laterprocess_directives()call on the same instance. Moot for this bug now that the throw is prevented, but it is a real fragility.is_array()branch in the directive dispatch in_process_directives()is unreachable: nothing writes to$directive_processors, so no value is ever an array callable.Trac ticket: https://core.trac.wordpress.org/ticket/65740
Trac ticket: https://core.trac.wordpress.org/ticket/64898
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 5
Used for:
data_wp_bind_processormethod was unused. See 1638ca3._doing_it_wrong()messages.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.