Skip to content

Style states: Use symbols for property keys to avoid clashes#79210

Merged
talldan merged 9 commits into
trunkfrom
use/symbols-for-style-states
Jun 22, 2026
Merged

Style states: Use symbols for property keys to avoid clashes#79210
talldan merged 9 commits into
trunkfrom
use/symbols-for-style-states

Conversation

@talldan

@talldan talldan commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

What?

The way style state keys are plain keys in global styles / block attributes has been pointed out as a potential risk

An example of this is something like this global style state, where mobile is the style state key:

"core/group": {
	"mobile": {
		"color": {
			"text": "hotpink"
		}
	},
},

In the future it may be possible for users to define arbitrary responsive states, so the mobile key could be anything, potentially clashing with known keys that are used for other style properties.

How?

Two potential solutions spring to mind to solve this. The first option could be to nest the properties in a states property. This is made difficult by pseudo states already being shipped in WordPress.

The second is to use some kind of prefix for the state keys. This is actually already somewhat in place, pseudo states are always prefixed with a : (e.g. :hover).

@MaggieCabrera has also been looking at implementing custom states, currently there's an @current state for styling the navigation block's current menu item in global styles.

The @ prefix is actually perfect for responsive states, as it mirrors the css syntax. This PR proposes using the following prefixes:

  • : for pseudo states (e.g. :hover, :focus). This remains unchanged
  • @ for responsive states (e.g. @mobile, @tablet). This is newly prefixed.
  • - for 'custom' states (e.g. -current). This was previously @, but is now -. These custom states are generally represented by classnames that are applied to a block. A . prefix is an option, but it might make paths to properties a bit unusual (e.g. @mobile..current.color.text vs @mobile.-current.color.text)

Testing Instructions

This needs a smoke test of the various features to ensure they work the same as in trunk.

  • Pseudo states are currently implemented on the button block in theme.json, global styles and on block instances
  • Responsive state are currently implemented pretty universally on every block in theme.json, global styles and on block instances.
  • Custom states are only implemented in theme.json at the moment I think.

Use of AI Tools

Codex / Opencode

@talldan talldan self-assigned this Jun 16, 2026
@talldan talldan added [Type] Enhancement A suggestion for improvement. [Feature] Style States Related to block style states (currently viewport and pseudo-states) labels Jun 16, 2026
@github-actions github-actions Bot added [Package] Editor /packages/editor [Package] Block library /packages/block-library [Package] Block editor /packages/block-editor labels Jun 16, 2026
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

Size Change: +970 B (+0.01%)

Total Size: 7.5 MB

📦 View Changed
Filename Size Change
build/modules/lazy-editor/index.min.js 14.4 kB +167 B (+1.17%)
build/scripts/block-editor/index.min.js 381 kB +236 B (+0.06%)
build/scripts/block-library/index.min.js 324 kB -1 B (0%)
build/scripts/blocks/index.min.js 44.8 kB +92 B (+0.21%)
build/scripts/edit-site/index.min.js 298 kB +232 B (+0.08%)
build/scripts/editor/index.min.js 473 kB +244 B (+0.05%)

compressed-size-action

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

Flaky tests detected in 998c47f.
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/27932793016
📝 Reported issues:

"selectors": {
"states": {
"@current": ".wp-block-navigation .current-menu-item"
"-current": ".wp-block-navigation .current-menu-item"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we talked about not needing the prefix for this? but it's not a blocker for this PR, we could do it on a followup

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I personally think it might be good to always use a prefix for style states as a convention. Otherwise, there's a situation where most states use prefixes, one or two don't and it's a bit inconsistent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Will third parties be able to define custom states at any point? If they won't, then it's not a problem either way as long as custom states don't use the same prefix as viewport states. But I agree that if all the other states have a prefix we might as well give one to custom states too 😄

Tbh I'm not convinced our hypothetical arbitrary viewport values would be a problem either, we could always just prefix anything theme- or plugin-defined. But if folks want to try this approach, this is as good a way of doing it as any (and better than an extra level of nesting imo)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I agree with @tellthemachines here, I never was very convinced that the custom naming was a real problem here. And I think we should support 3rd parties adding states at some point, it could open up a lot of options for custom blocks.

@tellthemachines tellthemachines left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is testing well for me, except of course that any existing responsive styles cease to be recognised.

The code changes look pretty straightforward.

What do we still need here for this to get to ready status? Shall we include some temporary back compat?

@MaggieCabrera

Copy link
Copy Markdown
Contributor

Custom states are only implemented in theme.json at the moment I think.

This is correct

@talldan

talldan commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

What do we still need here for this to get to ready status? Shall we include some temporary back compat?

I'm looking at the back compat, just working through the options to try to come up with something relatively clean and not too difficult to remove in the future.

#### Responsive styles

Block styles can be scoped to two named breakpoints: `mobile` and `tablet`. Any style property that is valid at the block or element level can be nested under one of these keys.
Block styles can be scoped to two named breakpoints: `@mobile` and `@tablet`. Any style property that is valid at the block or element level can be nested under one of these keys.

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 did some drive-by smoke testing, it's working well and I like the syntax direction!

Especially @mobile / @tablet for responsive states... avoids style property clashes without changing the shape.

One thing that occurred to me for later in the cycle: as pseudo, responsive, and custom selector states grow, we'll probably want some small unifying doc for "style states" and their key prefixes.

They seem to be documented separately, so a convention exists but could be explicit.

@talldan talldan force-pushed the use/symbols-for-style-states branch from fd5ebe7 to 58940f4 Compare June 18, 2026 04:46
@github-actions github-actions Bot added the [Package] Blocks /packages/blocks label Jun 18, 2026
@talldan talldan force-pushed the use/symbols-for-style-states branch 2 times, most recently from a40e232 to 8199f01 Compare June 18, 2026 08:24
@youknowriad

Copy link
Copy Markdown
Contributor

Thanks for running with this idea folks, I appreciate it :)

@talldan talldan marked this pull request as ready for review June 18, 2026 10:08
@github-actions

github-actions Bot commented Jun 18, 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.

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: talldan <[email protected]>
Co-authored-by: MaggieCabrera <[email protected]>
Co-authored-by: tellthemachines <[email protected]>
Co-authored-by: ramonjd <[email protected]>
Co-authored-by: andrewserong <[email protected]>
Co-authored-by: youknowriad <[email protected]>

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

@talldan

talldan commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

There should be back compat now, and from what I can tell it's working. 😄

@andrewserong andrewserong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work on this @talldan! It's been testing well for me so far.

One question came up while running this through a Claude session: and that's if it's possible to only perform the normalization for global styles once on load rather than every time we call setStyle? I'm not sure if it's really an issue in practice, but currently it looks like the normalization in setStyle does a full tree traversal via normalizeStyleStateAliases and then normalizeStyleStateNode, so I'm wondering if it's possible to perform this once, rather than every time we set the style?

@talldan

talldan commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

One question came up while running this through a Claude session: and that's if it's possible to only perform the normalization for global styles once on load rather than every time we call setStyle?

Yeah, that's a good call. I'll see if it's possible.

@talldan

talldan commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

@andrewserong I can't see a way that this is possible, at least not without some re-plumbing that might cause unknown side effects.

The best candidate for migrating incoming user style values looks to be useGlobalStylesUserConfig to my eye. That's essentially the first layer after the core data store.

The part that looks problematic to me is that when setting global styles, the code gets them first from the core store:

const setConfig = useCallback(
/**
* Set the global styles config.
* @param {Function|Object} callbackOrObject If the callbackOrObject is a function, pass the current config to the callback so the consumer can merge values.
* Otherwise, overwrite the current config with the incoming object.
* @param {Object} options Options for editEntityRecord Core selector.
*/
( callbackOrObject, options = {} ) => {
const record = getEditedEntityRecord(
'root',
'globalStyles',
globalStylesId
);

That result from getEditedEntityRecord will potentially have unmigrated values within it if there have been no edits, and those unmigrated values will be merged into the new updated value.

There are two options:

  • Remove that getEditedEntityRecord call. Is it there for a reason? Is the same value from the closure going to be stale? Doing this might have some unexpected side effects.
  • Migrate the value in setConfig. That results in essentially the same thing that's in this PR already, just in a different place.

@andrewserong

Copy link
Copy Markdown
Contributor

Thanks for digging in @talldan! I suspect it's a negligible issue given that a) the tree likely isn't going to be very big and b) the migration is guarded behind running just in the GB plugin. I think we could likely go with what you've got and revisit if it turns out to be a problem.

@andrewserong andrewserong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just giving this a tentative approval as I'm about to go AFK for a long weekend and wanted to signal that I think this a good approach and feels like it's in good shape to land.

I've left a comment about whether it's worth moving resolve_style_state_aliases to a utility function in a separate file if we know we're not going to backport it.

Other than that, I think the main thing to confirm is just that everyone's happy with the new prefixes. It seems so! As such my vote would be to get this PR in, and if there's any performance issues with the transformations, we could look into those in follow-ups.

In my manual testing this was all working nicely with post content created before this PR being handled correctly on the site frontend, and migrated in post content once I go to make changes to a post. I'm not sure I tested all the changes thoroughly, but I used the following markup for my smoke testing:

<!-- wp:group {"style":{"spacing":{"blockGap":"48px"},"mobile":{"spacing":{"blockGap":"4px"}}},"layout":{"type":"default"}} -->
<div class="wp-block-group">
<!-- wp:paragraph {"style":{"color":{"text":"#1e1e1e"},"mobile":{"color":{"text":"#cc0000"}},"tablet":{"color":{"text":"#0000cc"}}}} -->
<p class="has-text-color" style="color:#1e1e1e">Legacy responsive paragraph: red below 480px, blue 480–782px, default above. The gap between these two paragraphs also shrinks on mobile.</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Second paragraph (used to show the responsive blockGap).</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:group -->

LGTM!

Comment thread lib/class-wp-theme-json-gutenberg.php Outdated
Comment on lines +943 to +956
/**
* Resolves temporary aliases for persisted style state keys to their canonical keys.
*
* This keeps compatibility for Gutenberg plugin content created before
* responsive states used '@' and custom states used '-'. Remove after the
* deprecation window ends.
*
* DO NOT BACKPORT TO CORE.
*
* @param array $styles Persisted style data.
* @param string|null $block_name Current block name, when walking block styles.
* @return array Style data with canonical style state keys.
*/
public static function resolve_style_state_aliases( $styles, $block_name = null ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not a blocker: but if we know we don't want to backport this to core, would it make sense to have this be a utility function that lives outside of this file, e.g. something like gutenberg_resolve_style_state_aliases? That way it's kept a bit separate from the overall Theme JSON class?

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.

Alternatively, in /lib/experimental or lib/compat/plugin or lib/block-supports/states.php ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

lib/compat/plugin looks like the right option 👍

Comment on lines +168 to +170
if ( normalizedStyle !== newAttributes.style ) {
newAttributes.style = normalizedStyle;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This tripped me up for a second, I'm so used to functions that always return new objects but this only returns a new object if the original one had the legacy state, so the strict equality check with two objects is the right check. Again not a blocker, but is it worth adding a comment to explain this nuance? (No worries if it feels obvious to you or others!)

@ramonjd

ramonjd commented Jun 22, 2026

Copy link
Copy Markdown
Member

This is testing well for me as well. Great work. 💯

Tested with the following HTML for backwards compat. Block editor and frontend LGTM.

Example block HTML
<!-- wp:group {"style":{"mobile":{"border":{"color":"#00b7fa","width":"34px"}},"tablet":{"border":{"color":"#00661d","width":"7px"}},"border":{"color":"#bd0000","width":"84px"}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group has-border-color" style="border-color:#bd0000;border-width:84px"><!-- wp:group {"style":{"color":{"background":"#e14141"},"tablet":{"color":{"background":"#0cdd08"},"typography":{"fontSize":"var:preset|font-size|x-large"}},"mobile":{"color":{"background":"#33c8e6"},"typography":{"fontSize":"var:preset|font-size|xx-large"}}},"fontSize":"large","layout":{"type":"constrained"}} -->
<div class="wp-block-group has-background has-large-font-size" style="background-color:#e14141"><!-- wp:paragraph {"style":{"color":{"text":"#ff0000"},"elements":{"link":{"color":{"text":"#ff0000"}}},"tablet":{"color":{"text":"#009936"},"elements":{"link":{"color":{"text":"#009936"}}}},"mobile":{"color":{"text":"#5266ff"},"elements":{"link":{"color":{"text":"#5266ff"}}}}}} -->
<p class="has-text-color has-link-color" style="color:#ff0000">Heading</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph {"style":{"elements":{"link":{"color":{"text":"var:preset|color|accent-1"},":hover":{"color":{"text":"#f50000"}}}},"tablet":{"elements":{"link":{"color":{"text":"#00ffd4"},":hover":{"color":{"text":"#079400"}}}}},"mobile":{"elements":{"link":{"color":{"text":"#00f7ff"},":hover":{"color":{"text":"#fffb19"}}}}}}} -->
<p class="has-link-color"><a href="https://playground.wordpress.net/scope:sad-vintage-garden/sample-page/" data-type="page" data-id="2">paragraph</a></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group --></div>
<!-- /wp:group -->

<!-- wp:paragraph -->
<p></p>
<!-- /wp:paragraph -->

};
}

const normalizedStyle = normalizeStyleStateAliases( newAttributes.style );

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.

Worth gating behind newAttributes.style?

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.

Sorry, I mean something like if ( newAttributes.style ) { so it's only called when style is present.

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 should have checked: normalizeStyleStateAliases already does it 👍🏻

@talldan

talldan commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

I think I've fixed all the feedback, so I'll go ahead and merge. I've a core PR here implementing the same PHP changes - WordPress/wordpress-develop#12253. I'll see if I can also push a commit to WordPress/wordpress-develop#11174

thanks for all the reviews 🎉

@talldan talldan merged commit 8ace4f7 into trunk Jun 22, 2026
41 checks passed
@talldan talldan deleted the use/symbols-for-style-states branch June 22, 2026 07:07
@talldan talldan changed the title Use symbols for style states to avoid property clashes Style states: Use symbols for property keys to avoid clashes Jun 22, 2026
@github-actions github-actions Bot added this to the Gutenberg 23.5 milestone Jun 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Style States Related to block style states (currently viewport and pseudo-states) [Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Package] Blocks /packages/blocks [Package] Editor /packages/editor [Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants