Make WordPress Core

Opened 5 weeks ago

Last modified 4 weeks ago

#65833 new defect (bug)

theme.json settings.viewport: mixed em/px breakpoints are ordered using a hardcoded 16px, emitting a media query that can never match

Reported by: courane01 Owned by:
Priority: normal Milestone: Future Release
Component: Editor Version: 7.1
Severity: normal Keywords: has-patch has-unit-tests
Cc: Focuses: accessibility

Description

Follow-up to #65596, which added settings.viewport to theme.json in 7.1.

When a theme declares mobile and tablet in different units (for example em and px), core validates the ordering in normalized pixels but emits the media query in the authored units. Browsers resolve em/rem in a media query against the user's own default font size, not against 16px — so a config that passes every validation core offers can emit a range that never matches, and does so only for some readers.

The mechanism

WP_Theme_JSON::get_viewport_breakpoint_value_in_pixels() converts em/rem with a fixed multiplier, wp-includes/class-wp-theme-json.php:790-810:

return 'px' === $unit ? $number : $number * 16;

The docblock at :806-807 acknowledges the split: the pixel value is used "only ... to compare breakpoint order; generated media queries keep the original units."

The ordering guard at :854 compares those normalized values:

if ( isset( $breakpoints['tablet'] ) && $breakpoints['mobile']['px'] < $breakpoints['tablet']['px'] ) {

…but get_viewport_media_queries() emits the authored units (:726, :732-734). The guard's proof therefore does not transfer to the browser.

Steps to reproduce

A theme theme.json containing:

{
  "version": 3,
  "settings": {
    "viewport": { "mobile": "30em", "tablet": "500px" }
  }
}

Both values are valid (px|em|rem), and the guard passes because 30 × 16 = 480 < 500. Core emits:

@mobile    @media (width <= 30em)
@tablet    @media (30em < width <= 500px)
@desktop   @media (width > 500px)

Resolved against the reader's default font size:

root font size @tablet resolves to result
9px 270px < width <= 500px matches a 230px band
12px 360px < width <= 500px matches a 140px band
16px 480px < width <= 500px matches a 20px sliver
18px 540px < width <= 500px never matches
20px 600px < width <= 500px never matches
24px 720px < width <= 500px never matches

Consequence for block visibility

This is not only a styling curiosity. wp_render_block_visibility_support() consumes these queries at render (wp-includes/block-supports/block-visibility.php:47-53). A paragraph storing:

<!-- wp:paragraph {"metadata":{"blockVisibility":{"viewport":{"tablet":false}}}} -->

renders with class wp-block-hidden-tablet and emits:

@media (30em < width <= 500px){.wp-block-hidden-tablet{display:none !important;}}

So an author's stored "hide on tablet" silently stops applying — for some readers and not others, from the same HTML.

Verified in a browser

At a 490px viewport with the browser default of 16px, (30em < width <= 500px) matches. Forcing html{font-size:24px !important} changes the root font size to 24px but does not change the match, and the media-query em base measures 16.00px. This confirms the emitted query is governed by the reader's browser preference and cannot be corrected by theme or site CSS.

Expected

Either prevent the silent inversion — reject mixing units within one viewport object, or require both breakpoints share a unit — or emit a wp_trigger_error() when accepting a mixed-unit pair whose ordering depends on the 16px assumption. That would be consistent with settings.spacing.spacingScale, which already warns on invalid values (class-wp-theme-json.php:5359).

Actual

The pair is accepted silently. No warning, no _doing_it_wrong — verified with a control _doing_it_wrong() call that did fire, so the silence is real rather than an unhooked probe.

Why this matters

The readers affected are exactly those who enlarge their default font size. And em-based breakpoints are the accessibility-recommended practice — what a careful theme author reaches for so layout responds to user font size. This implementation makes that the failing case, while px breakpoints are always safe.

The editor cannot surface it either: wp-includes/js/dist/block-editor.js:11793-11862 mirrors the same constants, the same 16px normalization and the same raw-unit emission, so the author previews the identical broken result at their own font size.

Not covered by existing tests

The tests added in wordpress-develop#12395 cover same-unit pairs (64rem/40rem) and invalid units (100%), including named cases for the drop and the max-width reinterpretation. No test mixes em/rem with px in one viewport object.

Environment

WordPress 7.1-RC2, PHP 8.3, single site, Twenty Twenty-Five with a child theme declaring settings.viewport. Behavior identical in RC1. Neither core's theme.json nor Twenty Twenty-Five declares settings.viewport, so only themes that opt in are affected.

Change History (8)

#1 @wildworks
5 weeks ago

  • Milestone Awaiting Review7.1

Thanks for the report. Let's investigate if there's anything we can address in the 7.1 release.

cc @isabel_brison @ramonopoly

#2 @bejignesh
4 weeks ago

Had a look at this since it is milestoned for 7.1.

One thing that might narrow the fix: in a media query, em and rem are both resolved against the initial font size rather than anything set on the page (Media Queries 4, section 1.3), so the two share a base. That means mobile: 30em, tablet: 40rem is correctly ordered at any font size, and it is specifically px against em or rem that cannot be ordered. Rejecting mixed units outright would catch the em/rem case too.

Would it work to keep tablet only when both values are measured against the same base, and drop it otherwise, the way it is already dropped when tablet is not larger than mobile? That also avoids adding a string, which I assume matters at this point in the cycle.

I have a patch roughed out locally along those lines. Happy to open a PR if that direction sounds right.

#3 @wildworks
4 weeks ago

Would it work to keep tablet only when both values are measured against the same base, and drop it otherwise, the way it is already dropped when tablet is not larger than mobile? That also avoids adding a string, which I assume matters at this point in the cycle.

Personally, I think this approach is fine. However, as a follow-up, I believe we should dipslay a warning message if different reference values are used or if the tablet is smaller than the mobile device.

Viewport units in px are not recommended when using viewport units in em or rem.
The viewport for Tablet is smaller than the viewport for Mobile.

This ticket was mentioned in PR #12948 on WordPress/wordpress-develop by @bejignesh.


4 weeks ago
#4

  • Keywords has-patch has-unit-tests added

WP_Theme_JSON::sanitize_viewport_settings() proves that tablet is larger than
mobile using values normalized at a hardcoded 16px, but
get_viewport_media_queries() emits the authored units. A media query resolves em and
rem against the initial font size rather than anything set on the page, so the ordering
proven at 16px does not transfer to the browser when the two breakpoints are measured
against different bases.

With mobile: 30em, tablet: 500px the guard passes because 30 x 16 = 480 < 500, and core
emits @media (30em < width <= 500px). Above a 16px base that range is empty, and
@media (width <= 30em) and @media (width > 500px) begin to overlap instead, so two
viewport states apply at once.

em and rem share a base in a media query, so they can be ordered against each other.
A px length and a font-relative one cannot, at any base. This adds
get_viewport_breakpoint_base() and keeps tablet only when both breakpoints are
measured against the same base, dropping it in the mixed case exactly as it is already
dropped when it is not larger than mobile.

No new string, so it is available after hard string freeze. settings.viewport is new in
7.1 and unreleased, so no existing theme depends on the current behavior.

Approach agreed with wildworks in comment:3. The warning message requested there is a
follow-up rather than part of this change, since it needs a translatable string.

Gutenberg mirrors this logic in packages/global-styles-engine/src/utils/viewport.ts and
lib/class-wp-theme-json-gutenberg.php, so the editor preview needs the same change
upstream. Not part of this PR.

## Testing instructions

In a theme's theme.json:

{ "version": 3, "settings": { "viewport": { "mobile": "30em", "tablet": "500px" } } }

Add a paragraph with block visibility set to hide on tablet, then view the post. Before
this change the page emits @media (30em < width <= 500px). After it, tablet is not a
configured breakpoint and only the mobile query is emitted. mobile: 30em, tablet: 40rem
keeps both breakpoints, since those share a base.

## Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Investigation, browser measurements, drafting the change and the tests. I
reviewed, tested and take responsibility for everything in this pull request.

#5 @bejignesh
4 weeks ago

Filed #65841 for the warning. It needs a translatable string, so it cannot ride along this cycle.

@wildworks commented on PR #12948:


4 weeks ago
#6

@jigneshbhavani Thanks for the PR! I have submitted a similar PR to Gutenberg. https://github.com/WordPress/gutenberg/pull/81388 Let's ask for opinions on this approach. Also, I made minor code refactoring and test changes in 4d14624e4fbd82955816f18931a16c1b7c5c584b.

#7 @isabel_brison
4 weeks ago

@courane01 I would be thankful for a human-written summary of what the actual breakage is; that description is extremely hard to read.

What I see in the example is a media query that even at a 16px base font size resolves to a difference of 20px between breakpoints. That isn't a realistic scenario. If we change the media query to a more reasonable 30rem for mobile and 800px for tablet, it still shows tablet styles at a 24px base font size. Granted, it only shows them for an 80px span, but it does work.

I can't imagine most theme authors would mix units in defining their breakpoints, but if they happen to have valid reasons do so, we try to provide working output within what's possible to calculate (we don't have access to the user's browser font size settings, so we have to assume the most common default). If we were to ditch the tablet breakpoint where units are different (which is what we do if tablet is smaller than mobile), the practical result for a valid media query at a 16px base size but invalid at a 24px base size is exactly the same as what we're doing now: the tablet styles don't apply for the invalid media query. But currently it will apply at smaller base sizes, whereas if we remove it there ceases to be a tablet breakpoint even for valid media queries with mixed breakpoints.

I personally don't believe that will serve anyone. We don't prevent theme authors or users from creating layouts that are inaccessible or awkward to navigate at high zoom levels. Even just using px units for dimensions can cause problems in that respect. We usually assume they will make sensible decisions about the accessibility of their theme or website, and give them freedom and flexibility to build whatever they like. We try to provide tools to do things well, and in a variety of ways; there should be no need to preventively prohibit a niche use case that _might_ go wrong in a very specific scenario.

#8 @wildworks
4 weeks ago

  • Milestone 7.1Future Release
{
  "version": 3,
  "settings": {
    "viewport": { "mobile": "30em", "tablet": "500px" }
  }
}

Upon further reflection, while specifying such an extreme viewport is indeed rare, this issue might not occur in real-world scenarios. It's likely not a bug that needs fixing in the 7.1 release.

Note: See TracTickets for help on using tickets.