Skip to content

Fix a fatal error when binding non-scalar values, and static analysis issues, in WP_Interactivity_API - #12725

Closed
westonruter wants to merge 40 commits into
WordPress:trunkfrom
westonruter:fix/phpstan-interactivity-api-bind-processor
Closed

westonruter wants to merge 40 commits into
WordPress:trunkfrom
westonruter:fix/phpstan-interactivity-api-bind-processor

Conversation

@westonruter

@westonruter westonruter commented Jul 27, 2026 •

Copy link
Copy Markdown
Member

Static analysis work on WP_Interactivity_API (Core-64898) surfaced a fatal error when data-wp-bind binds 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 a TypeError, because that method is typed string|bool and passes the value straight to the escaping functions. Both escaping paths failed, in different places:

Bound attribute Failure
Ordinary, e.g. id TypeError: strtr(): Argument #1 ($string) must be of type string, array given in WP_HTML_Tag_Processor::set_attribute()
URI, per wp_kses_uri_attributes(), e.g. href TypeError: ltrim(): Argument #1 ($string) must be of type string, array given, from inside esc_url()

This is reachable from post content alone, with no plugin code, because data-wp-context can supply the array inline. Paste either of these into the post editor's code editor (⌥⌘M) to reproduce on trunk:

<!-- wp:accordion -->
<div class="wp-block-accordion"><span data-wp-interactive="myPlugin" data-wp-context='{"list":[1,2,3]}' data-wp-bind--id="context.list">Boom</span></div>
<!-- /wp:accordion -->
<!-- wp:accordion -->
<div class="wp-block-accordion"><span data-wp-interactive="myPlugin" data-wp-context='{"list":[1,2,3]}'><a data-wp-bind--href="context.list">Boom</a></span></div>
<!-- /wp:accordion -->

The core/accordion wrapper is needed only because directives are processed for blocks whose supports.interactivity is true; any of those works. The directives have to sit on a nested element, since the accordion's own render filter overwrites data-wp-interactive and data-wp-context on 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 a null value 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.

Object Before Client store After
__toString() only id="the-string-form" {"prop":"prop value"} notice, attribute unset
__toString() + jsonSerialize() agreeing id="the-string-form" "the-string-form" id="the-string-form"
__toString() + jsonSerialize() differing id="the-string-form" {"not":"the string"} notice, attribute unset
JsonSerializable, no __toString() fatal "the-string-form" id="the-string-form"
serializing to another serializable object notice, attribute unset "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 calling jsonSerialize() 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 null check also means one serializing to null removes 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:

value (string) wp_json_encode() JS String(v)
1.5 under LC_NUMERIC=de_DE 1,5 1.5 1.5
1/3 0.33333333333333 0.3333333333333333 0.3333333333333333

Casting a float is locale-dependent before PHP 8.0 (RFC), and 7.4 is the minimum supported version. The cast also rounds to precision where the store uses serialize_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 returns false, and WP_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 received INF or NAN, which means nothing as an attribute value and does not match the client either, where the same numbers render as Infinity and NaN.

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_processors gains an array shape whose values are the literal method names. Without it, the dynamic dispatch through call_user_func_array() is unresolvable, so all eight data_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 destructuring null. The get_attribute() null check is defensive: the attribute names come from get_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::$attributes and get_attribute_names_with_prefix() gain @phpstan- annotations only. No functional change.
  • data_wp_bind_processor() gains a void return type.

Testing instructions

npm run test:php -- --group interactivity-api

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 the strtr() and esc_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 from data_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_stack and $namespace_stack only on the unbalanced-tags path, not when an exception is thrown mid-traversal. A caught fatal therefore corrupts every later process_directives() call on the same instance. Moot for this bug now that the throw is prevented, but it is a real fragility.
  • The 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:

  • Troubleshooting PHPStan complaining that the data_wp_bind_processor method was unused. See 1638ca3.
  • Writing tests.
  • Resolving a bound object to whatever it serializes to, and the wording of the _doing_it_wrong() messages.
  • Implementing and verifying the number formatting. Using the JSON encoder for it was my own approach, prompted by @dmsnell raising the locale question in review.
  • Rejecting INF and NAN, after I asked whether they belonged with the other values which cannot reach the client.
  • Drafting this description. The behavior tables and the reproduction snippets above were verified against a local install rather than asserted.

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.

Copilot AI review requested due to automatic review settings July 27, 2026 23:34
Comment on lines +1083 to +1102
/*
* 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;
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@dmsnell How does this look to you?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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()?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

for reference, here is the CSS locale issue

WordPress/gutenberg#57796

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Image

Interesting that PHP 5.2 outputs the comma as delimiter! (But we don't care anymore.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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! 👍

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, null attribute lists/values, invalid directive names).
  • Ensures data-wp-bind only 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.

Comment thread src/wp-includes/interactivity-api/class-wp-interactivity-api.php
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

westonruter and others added 4 commits July 27, 2026 16:56
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>
Copilot AI review requested due to automatic review settings July 28, 2026 01:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php Outdated
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 );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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>
Copilot AI review requested due to automatic review settings July 28, 2026 01:35
@westonruter
westonruter marked this pull request as ready for review July 28, 2026 01:39
@github-actions

github-actions Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

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.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, luisherranz, dmsnell, darerodz.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

$suffix_index = strpos( $name, '--' );

if ( false === $suffix_index ) {
if ( false === $suffix_index || 0 === $suffix_index ) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As I understand, data-wp---enable--bind is invalid because anything after --- is supposed to be the unique ID:

Image

So it is invalid to have a unique ID without anything to make it unique.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 suffix until three hyphens are found, in which case that's where the unique-id starts.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 ) ) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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:

Suggested change
if ( '' === $name || preg_match( '/[^a-z0-9\-_]/i', $name ) ) {
if ( 1 !== preg_match( '/^[a-z0-9\-_]+$/i', $name ) ) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in a7a46b6 and b1cb905.

@westonruter westonruter changed the title Fix static analysis issues in WP_Interactivity_API::data_wp_bind_processor() Fix a fatal error when binding non-scalar values, and static analysis issues, in WP_Interactivity_API Jul 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this probably works by elimination, but the check is truly this:

Suggested change
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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in 7430b7f

westonruter and others added 3 commits July 29, 2026 21:49
…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>
Copilot AI review requested due to automatic review settings July 30, 2026 05:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 || null will 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 uses empty(), 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 || null would 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’s empty() 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”, but data_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'] )

westonruter and others added 3 commits July 29, 2026 22:34
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>
Copilot AI review requested due to automatic review settings July 30, 2026 05:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@westonruter
westonruter requested a review from luisherranz July 30, 2026 06:03

@luisherranz luisherranz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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>
Copilot AI review requested due to automatic review settings July 30, 2026 15:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.:

@westonruter

Copy link
Copy Markdown
Member Author

This is a bit unexpected to me , but it also is the behavior in trunk, which I just realized.

@luisherranz I think I got it. How about 0d8e023?

pento pushed a commit that referenced this pull request Jul 30, 2026
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
@github-actions

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 62944
GitHub commit: 9d33f69

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions Bot closed this Jul 30, 2026
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Jul 30, 2026
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, '--' );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@luisherranz

Copy link
Copy Markdown
Member

@luisherranz I think I got it. How about 0d8e023?

Looks great! I'll prepare the fix to sync it in Gutenberg, thank you very much! 🙂

@luisherranz

Copy link
Copy Markdown
Member

Gutenberg PR for the syntax backport here:

@luisherranz

Copy link
Copy Markdown
Member

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants