Skip to content

Theme_JSON: Prevent implicit coercion in to_ruleset#76392

Merged
ramonjd merged 8 commits intotrunkfrom
update/fix-to-ruleset-casting
Mar 13, 2026
Merged

Theme_JSON: Prevent implicit coercion in to_ruleset#76392
ramonjd merged 8 commits intotrunkfrom
update/fix-to-ruleset-casting

Conversation

@ramonjd
Copy link
Member

@ramonjd ramonjd commented Mar 11, 2026

Fix: Prevent implicit coercion in to_ruleset

What

to_ruleset used 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 style theme.json path in test_get_styles_with_appearance_tools() to simulate a style node. Before it was settings.

Discovered while stomping around Theme_JSON in #73117

How

  • Cast numeric values to string explicitly.
  • Skip declarations whose value is not a string or number (booleans, arrays, objects).

Test

test_to_ruleset_skips_non_scalar_values_and_casts_numerics checks that:

  • Strings pass through.
  • Numerics are cast to string and included.
  • Booleans and arrays are skipped.

@ramonjd ramonjd self-assigned this Mar 11, 2026
@ramonjd ramonjd added [Type] Bug An existing feature does not function as intended [Feature] Layout Layout block support, its UI controls, and style output. labels Mar 11, 2026
Comment on lines +2115 to +2124
$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 . ';';
},
Copy link
Member Author

Choose a reason for hiding this comment

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

This fixes the root issues. $element['value'] could be anything and PHP silently coerces it without the checks.

$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.
Copy link
Contributor

Choose a reason for hiding this comment

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

When appearanceTools enables blockGap but no explicit CSS value is provided in styles

Shouldn't this case be covered by the default gap value?

Copy link
Member Author

Choose a reason for hiding this comment

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

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

Copy link
Member Author

@ramonjd ramonjd Mar 12, 2026

Choose a reason for hiding this comment

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

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;}';
Copy link
Member Author

Choose a reason for hiding this comment

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

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

@ramonjd ramonjd requested a review from andrewserong March 12, 2026 03:40
@ramonjd ramonjd changed the title Fix CSS value handling in WP_Theme_JSON_Gutenberg to skip boolean feature flags Fix: Prevent implicit coercion in to_ruleset Mar 12, 2026
@ramonjd ramonjd changed the title Fix: Prevent implicit coercion in to_ruleset Theme_JSON: Prevent implicit coercion in to_ruleset Mar 12, 2026
ramonjd added 5 commits March 12, 2026 14:41
…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.
@ramonjd ramonjd force-pushed the update/fix-to-ruleset-casting branch from 140d7aa to 738991a Compare March 12, 2026 03:41
@ramonjd ramonjd marked this pull request as ready for review March 12, 2026 03:42
@github-actions
Copy link

github-actions bot commented Mar 12, 2026

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.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: ramonjd <[email protected]>
Co-authored-by: tellthemachines <[email protected]>
Co-authored-by: andrewserong <[email protected]>

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

@ramonjd
Copy link
Member Author

ramonjd commented Mar 12, 2026

If I've got things right according to folks, I'll prep a core patch.

@github-actions
Copy link

github-actions bot commented Mar 12, 2026

Flaky tests detected in a54f8c7.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/22990608681
📝 Reported issues:

);

$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;}';
Copy link
Contributor

Choose a reason for hiding this comment

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

The empty --wp--style--block-gap: ; is still here though, is that expected?

Copy link
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Contributor

@andrewserong andrewserong left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +7261 to +7264
/**
* @covers WP_Theme_JSON_Gutenberg::to_ruleset
*/
public function test_to_ruleset_skips_non_scalar_values_and_casts_numerics() {
Copy link
Contributor

@andrewserong andrewserong Mar 12, 2026

Choose a reason for hiding this comment

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

Whoops, I approved before checking the test results 😅

Does this one need a call to $reflection->setAccessible( true );?

The failure is:

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

Copy link
Member Author

Choose a reason for hiding this comment

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

LOL I deleted it because my local linter said it was deprecated. The tests pass for me. 🙃 I'll add it back it.

Thanks!!

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, it might just be the old PHP versions tests where it's needed, that might explain it!

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh because of a PHP version mismatch prolly

@tellthemachines
Copy link
Contributor

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.

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.

@andrewserong
Copy link
Contributor

True, but can this only happen for block gap?

From a quick skim I think so: looks like it's just this logic that assumes that array( 'styles', 'spacing', 'blockGap' ) will always have a value (because in core / GB it does). So looks like maybe it just needs an if statement there?

if ( isset( $this->theme_json['settings']['spacing']['blockGap'] ) ) {
$block_gap_value = static::get_property_value( $this->theme_json, array( 'styles', 'spacing', 'blockGap' ) );
$css .= ":where(.wp-site-blocks) > * { margin-block-start: $block_gap_value; margin-block-end: 0; }";
$css .= ':where(.wp-site-blocks) > :first-child { margin-block-start: 0; }';
$css .= ':where(.wp-site-blocks) > :last-child { margin-block-end: 0; }';
// For backwards compatibility, ensure the legacy block gap CSS variable is still available.
$css .= static::ROOT_CSS_PROPERTIES_SELECTOR . " { --wp--style--block-gap: $block_gap_value; }";
}

@ramonjd ramonjd enabled auto-merge (squash) March 13, 2026 00:03
@ramonjd ramonjd merged commit 3709d01 into trunk Mar 13, 2026
49 of 50 checks passed
@ramonjd ramonjd deleted the update/fix-to-ruleset-casting branch March 13, 2026 00:16
@github-actions github-actions bot added this to the Gutenberg 22.8 milestone Mar 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Layout Layout block support, its UI controls, and style output. [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants