Theme_JSON: Prevent implicit coercion in to_ruleset#76392
Conversation
| $value = $element['value']; | ||
| if ( is_numeric( $value ) ) { | ||
| $value = (string) $value; | ||
| } | ||
| // Skip declarations whose value is not a plain string (booleans, arrays, objects, etc.). | ||
| if ( ! is_string( $value ) ) { | ||
| return $carry; | ||
| } | ||
| return $carry .= $element['name'] . ': ' . $value . ';'; | ||
| }, |
There was a problem hiding this comment.
This fixes the root issues. $element['value'] could be anything and PHP silently coerces it without the checks.
phpunit/class-wp-theme-json-test.php
Outdated
| $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: ; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: ; }:root :where(.is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.is-layout-flow) > *{margin-block-start: 1;margin-block-end: 0;}:root :where(.is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.is-layout-constrained) > *{margin-block-start: 1;margin-block-end: 0;}:root :where(.is-layout-flex){gap: 1;}:root :where(.is-layout-grid){gap: 1;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}'; | ||
| // When appearanceTools enables blockGap but no explicit CSS value is provided in styles, | ||
| // the layout rules that use blockGap as a placeholder (margin-block-start, gap) should | ||
| // not be emitted. A boolean feature flag is not a CSS value. |
There was a problem hiding this comment.
When appearanceTools enables blockGap but no explicit CSS value is provided in styles
Shouldn't this case be covered by the default gap value?
There was a problem hiding this comment.
Thanks for flagging. I'll check more closely today with my morning glasses on. From what I saw a boolean value was sneaking its way through, and I don't know why. 🤔
There was a problem hiding this comment.
I'm not sure setting the default value is right here.
There's a comment that explicitly states the default value is set if block gap is not available.
// Use a fallback gap value if block gap support is not available.otherwise it's null in other cases.
What I'm seeing rather, and the issue I was trying to debug, is that the test_get_styles_with_appearance_tools() test passes the settings node path and not the styles node.
// Here $node === { settings: { ..., "spacing": { "blockGap": true, ... } } }
$block_gap_value = static::get_property_value( $node, array( 'spacing', 'blockGap' ) );It's setting the value to true which is the supports value, not a blockGap style value.
If there's no block gap value, then margin-block-start/end will fall through thanks to this condition:
if ( null !== $block_gap_value && false !== $block_gap_value && '' !== $block_gap_value ) {
I think it's just a matter of updating the test here and tightening up the to_ruleset coercion I think.
| 'selector' => 'body', | ||
| ); | ||
|
|
||
| $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: ; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: ; }:root :where(.is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.is-layout-flow) > *{margin-block-start: 1;margin-block-end: 0;}:root :where(.is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.is-layout-constrained) > *{margin-block-start: 1;margin-block-end: 0;}:root :where(.is-layout-flex){gap: 1;}:root :where(.is-layout-grid){gap: 1;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}'; |
There was a problem hiding this comment.
Notice --wp--style--block-gap: ; the value was always empty. It was a quirk of passing settings as the path in the test that made the following condition pass because the value was true:
if ( null !== $block_gap_value && false !== $block_gap_value && '' !== $block_gap_value ) {to_ruleset
to_rulesetto_ruleset
…ture flags. Update tests to reflect changes in blockGap placeholder behavior.
…This update ensures that only plain string values are processed, improving the handling of CSS rulesets.
…oving unnecessary comments and ensuring only valid CSS declarations are processed. Update test expectations to align with changes in blockGap placeholder behavior.
… values are skipped and numerics are cast to strings.
140d7aa to
738991a
Compare
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
If I've got things right according to folks, I'll prep a core patch. |
|
Flaky tests detected in a54f8c7. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/22990608681
|
| ); | ||
|
|
||
| $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: ; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: ; }:root :where(.is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.is-layout-flow) > *{margin-block-start: 1;margin-block-end: 0;}:root :where(.is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.is-layout-constrained) > *{margin-block-start: 1;margin-block-end: 0;}:root :where(.is-layout-flex){gap: 1;}:root :where(.is-layout-grid){gap: 1;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}'; | ||
| $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: ; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: ; }.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}'; |
There was a problem hiding this comment.
The empty --wp--style--block-gap: ; is still here though, is that expected?
There was a problem hiding this comment.
Good question.
It's because blockGap isn't present: https://github.com/WordPress/gutenberg/blob/trunk/lib/class-wp-theme-json-gutenberg.php#L3525-L3531
So it's expected, but not ideal I guess.
We could skip adding those styles if there's no blockGap?
I left that bit alone as I was afraid of blowing things up.
andrewserong
left a comment
There was a problem hiding this comment.
LGTM, good catch!
My hunch is that the empty --wp--style--block-gap isn't too big a deal since in practice Gutenberg's core theme.json will always have a value for the block gap.
We could skip adding those styles if there's no blockGap?
No harm in skipping if there isn't a value, but also not a blocker if you wanted to land this code quality improvement without addressing it.
| /** | ||
| * @covers WP_Theme_JSON_Gutenberg::to_ruleset | ||
| */ | ||
| public function test_to_ruleset_skips_non_scalar_values_and_casts_numerics() { |
There was a problem hiding this comment.
Whoops, I approved before checking the test results 😅
Does this one need a call to $reflection->setAccessible( true );?
The failure is:
- WP_Theme_JSON_Gutenberg_Test::test_to_ruleset_skips_non_scalar_values_and_casts_numerics
ReflectionException: Trying to invoke protected method WP_Theme_JSON_Gutenberg::to_ruleset() from scope ReflectionMethod
There was a problem hiding this comment.
LOL I deleted it because my local linter said it was deprecated. The tests pass for me. 🙃 I'll add it back it.
Thanks!!
There was a problem hiding this comment.
Ah, it might just be the old PHP versions tests where it's needed, that might explain it!
There was a problem hiding this comment.
Oh because of a PHP version mismatch prolly
True, but can this only happen for block gap? We should probably make sure not to output properties without values at some point in the flow. |
From a quick skim I think so: looks like it's just this logic that assumes that gutenberg/lib/class-wp-theme-json-gutenberg.php Lines 3445 to 3453 in 5f647a0 |
… method accessibility (deprecated in 8.5)
Fix: Prevent implicit coercion in
to_rulesetWhat
to_rulesetused string concatenation ($element['name'] . ': ' . $element['value'] . ';'), so PHP implicitly coerced non-string values (e.g. booleans →'1'/'', arrays →'Array'). That could emit invalid or misleading CSS.At the same time, pass a
styletheme.json path intest_get_styles_with_appearance_tools()to simulate a style node. Before it wassettings.Discovered while stomping around Theme_JSON in #73117
How
Test
test_to_ruleset_skips_non_scalar_values_and_casts_numericschecks that: