Fix a fatal error when binding non-scalar values, and static analysis issues, in WP_Interactivity_API - #12725
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.
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.
|
|
||
| // 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
Resolving a bound object through the JSON encoder calls JsonSerializable::jsonSerialize(), which is arbitrary code and may throw. Nothing caught it, so the exception escaped process_directives() and took down the render — the very thing checking the value is meant to prevent. It also left $context_stack and $namespace_stack unrestored, since _process_directives() only restores them on the unbalanced-tags path, so every later process_directives() call on the same instance was corrupted. A throw is now treated as an encoding failure: the object is left in place and reported by the existing non-scalar usage error, which gives the right advice for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Introduced in 359638a. Replacing end() with array_last() in get_directive_entries() changed the default namespace on an empty namespace stack from false to null, since that is what the two return for an empty array. The context processor uses the namespace as an array key, and where false coerced to the key 0, null is deprecated as an array offset in PHP 8.5, which the test suite converts to an error. That failed the five Tests_WP_Interactivity_API_WP_Context tests covering a context directive with no namespace, on every PHP 8.5 job. Such a context is skipped rather than keyed on an empty string. There is nothing to store it under, and the key 0 it was previously stored under could not be addressed by any reference either. No notice is reported here, as evaluate() already reports the missing namespace when the value is used. Note that PHP 8.5.0RC2 only deprecates the offset form, so this reproduces locally by writing the same key with $context[ $entry['namespace'] ] rather than in an array literal. The each processor uses the namespace as a key too, but is unreachable with a null one: evaluate() returns null first and the empty() guard above it returns early. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parse_directive_name() gained two conditions when it was given an honest return type: a name which is nothing but the "data-wp-" prefix is now rejected instead of yielding an empty prefix, and a leading "--" is kept as part of the prefix instead of being read as a suffix separator. Both were untested, as was the pre-existing rejection of a name containing characters a directive name cannot contain. Neither changes what a directive does, since an empty prefix names no registered directive either way. That is worth stating rather than assuming: the client's parseDirectiveName() still returns an empty prefix for both, so the two implementations now differ in shape while agreeing on the outcome. The comments record which is which. Also covers get_directive_entries() skipping a name it cannot parse. This one passes against trunk too — destructuring null happened to produce a prefix which matched nothing — so it locks in behavior which is now deliberate rather than incidental. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
data_wp_interactive_processor() pushes an entry for every directive it enters, because one is popped for every closing tag regardless of what the directive contained. Where the directive defines no usable namespace and no enclosing one is in effect to inherit, what it pushes is end() of an empty stack, which is false. The property was documented as holding strings, so get_directive_entries() passed that false straight through as the default namespace, in breach of the string|null it declares for the entries it returns. For a context directive it then arrived as an array key, where false coerces to 0 and so slipped past the check for a namespace which is absent. Only a string names a store, so anything else is now normalized to null before the value leaves this method, and the property says what it really holds. With the type corrected, the analysis reports the mismatch this addresses rather than trusting the annotation and passing over it. Note that the two readers which take end() of this stack as an array key, in state() and get_context(), depend on false coercing to 0. Pushing null instead of false would read better but is deprecated as an array offset in PHP 8.5, so both of those want fixing first. Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com> 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:1176
- The non-finite-float
_doing_it_wrong()message says “INF or NAN”, but this branch is also triggered for-INF(sincewp_json_encode()fails for both infinities). This makes the message slightly inaccurate/misleading for negative infinity cases. Consider updating the wording to include-INF(or to describe “non-finite values” more generally) and adjust the corresponding test expectations.
sprintf(
/* translators: %s: The attribute name. */
__( 'Attempted to bind INF or NAN to the "%s" attribute. JSON can represent neither, so the client is sent no state at all. Cast the value to a string before storing it in state or context.' ),
esc_html( $entry['suffix'] )
Resolving a bound object before the value checks means those checks never learn an object was involved, so the existing handling of what it resolves to applies unchanged. That composition was asserted in review but not in code: - An object serializing to null removes the attribute quietly, as a null value does, and reports nothing. Null is a value the client can be sent. - An object serializing to a boolean keeps the boolean attribute semantics, including the string form Preact writes for `data-` and `aria-` attributes. Also pins the number formatting against the store rather than against a literal, which is the invariant it exists for, and asserts it through the same encoder both sides use so the assertion holds whatever `serialize_precision` is set to. The last one documents a path worth knowing about. An object serializing to INF does not fail to encode the way a bare INF does: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from its public properties and so discards jsonSerialize() entirely. The value resolves to an empty object and is reported as non-scalar rather than as non-finite. The store is rebuilt the same way, so the client is sent that same empty object and the two still agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documentation and a return type, no behavior change. data_wp_bind_processor() gains an @SInCE entry for the object resolution, the number formatting, and the rejection of a value which cannot be sent to the client. Two comments claimed more than they deliver: - Resolving an object says the object is left in place when the encoding fails, which is true but rare. wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from its public properties and ignores jsonSerialize() entirely, so an object whose serialized form JSON cannot represent resolves to whatever that rebuild encodes to rather than failing. - Formatting a number says the attribute matches what the client receives, which holds for the locale and precision cases it was written for but not for exponent notation, negative zero, or an integer beyond the range JavaScript represents exactly. Casting diverged on all three too, so the comment now says which cases are closed rather than implying all of them are. Also names the float test for what it asserts, since not casting is the point of it, and declares the array return type on the test helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DAreRodz
left a comment
There was a problem hiding this comment.
This fix makes sense. Thanks, @westonruter. 🙏
I have read through the code, and the changes look good to me. I haven't had time to test it, though.
luisherranz
left a comment
There was a problem hiding this comment.
Hey @westonruter, thanks for working on this 🙂
In general it looks good, although this assumes the resolved value of a directive can only be something contained in state or context, but in reality it can also be derived state returned by a PHP closure.
The value the closure returns never reaches the client in any form. The store receives {} for that prop (the closure degrades to an empty object inside wp_json_encode()) plus its path in derivedStateClosures. The client value comes exclusively from the JS getter.
I'm not suggesting the behavior should be different for derived state. I think it should be the same either way, and rejecting non-scalar values uniformly seems right to me, since an array from a closure also fatals on trunk. With that in mind, I see two separate things in this PR.
The messages assume the stored case (state or context), and for closures they say things that aren't accurate.
- "Cast the value to a string before storing it in state or context" cannot be followed when the value comes from a closure, because nothing is stored. The fix there is to return a string from the closure.
- "JSON can represent neither, so the client is sent no state at all" is only true when the INF or NAN sits in stored state. Coming from a closure, it never enters the store, and the store encodes fine.
The change from __toString() to serialization for objects: The serialization reasoning covers stored values (state or context), where the value the client ends up with really is the serialized form, so resolving through the encoder computes exactly what the client will have. But for derived state there's no serialization involved at all, and the server can never know what the JS getter returns. Agreement there is the developer's responsibility for every value. This PR now rejects closures returning stringable objects, which is not backward compatible. The surface is very small, so I don't have a strong opinion on whether it should change or not. I just wanted to point it out to make the decision with that case in mind.
Another option to consider would be to replicate in PHP what JavaScript's String() does. An object becomes [object Object], an array becomes a comma join, and INF and NAN become "Infinity" and "NaN", although I'm not sure if, especially the [object Object] part, would make much sense.
|
@luisherranz Would you please push up commits to this branch that apply your suggestions?
IMO, it would be better to have a warning so that the author would be more readily discover their usage error. |
luisherranz
left a comment
There was a problem hiding this comment.
I've pushed the updated messages and comments to take into account that those values can also come from derived state closures, and I leave it up to your decision what to do with the reject of closures returning stringable objects.
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
- This expected
_doing_it_wrong()message says the bound value must resolve to a string, but the implementation allows any scalar. Update the expected message if the core notice is revised to mention “scalar value”.
'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
- This expected
_doing_it_wrong()message says the bound value must resolve to a string, but the implementation allows any scalar. Update the expected message if the core notice is revised to mention “scalar value”.
'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
- If the core
_doing_it_wrong()string is updated to accurately describe accepted values (scalar, not just string), the expected string in thissprintf()should be updated to match to avoid brittle test failures.
'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
- If the core
_doing_it_wrong()string is updated to accurately describe accepted values (scalar, not just string), the expected string in thissprintf()should be updated to match to avoid brittle test failures.
'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:1161
- The
_doing_it_wrong()message says the bound value must resolve to a string, but this code path explicitly allows all scalar values (string|bool|int|float, with non-finite floats rejected separately). This wording is misleading for developers debugging the notice; consider referring to a “scalar value” instead.
__( '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.' ),
src/wp-includes/interactivity-api/class-wp-interactivity-api.php:1193
- The non-finite number notice currently says the value must resolve to a string, but finite numbers are valid and handled (formatted via
wp_json_encode()). Adjusting the message to indicate “finite number or string” will make the guidance accurate.
__( 'Attempted to bind INF or NAN 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
- This test hard-codes the
_doing_it_wrong()message text. If the core notice is updated to accurately allow finite numbers (not only strings), the expected string here should be updated to match.
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.)',
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.