Skip to content

Pin the author cascade layer order ahead of engine support CSS - #1854

Merged
chubes4 merged 1 commit into
trunkfrom
fix/author-cascade-layer-order
Sep 16, 2026
Merged

chubes4 merged 1 commit into
trunkfrom
fix/author-cascade-layer-order

Conversation

@chubes4

@chubes4 chubes4 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Problem

A cascade layer's precedence comes from where its name is first registered, not from where its rules appear. Engine support CSS is free to join an author layer, but naming a layer registers it.

engine-support-after-author emits one such rule:

@layer utilities{@media (width>=48rem){:root .wp-block-navigation.md\:flex{display:flex!important}}}

On the frontend that stylesheet is enqueued after the author CSS, so it joins an already-registered layer and changes nothing. The block editor injects author CSS through block_editor_settings_all, which lands after the enqueued editor styles — so the support stylesheet is parsed first and registers utilities before the author's own layers exist.

Measured on an imported Tailwind v4 site, same element in both documents:

Frontend — author stylesheet registers every layer, utilities last:

332 properties → 336 theme → 338 base → 401 components → 402 utilities

Editor — engine support registers utilities first, demoting it below base:

2688 utilities   ← engine-support-after-author
2715 properties → 2717 theme → 2718 base → 2721 components

With base outranking utilities, the author's own preflight reset (@layer base { *{margin:0;padding:0} }) beats the author utilities that centre and pad every section:

frontend editor
.container-site margin-left 120px 0px
.container-site max-width 1200px 1200px
section padding 112px 0 0px

Every section rendered full-bleed and left-aligned in the editor — cards clipped by the canvas edge, page height 3760 vs 5290 — while the frontend stayed correct. max-width survived only because nothing in base sets it.

Fix

Read the layer order the author stylesheet establishes for itself and state it ahead of every engine stylesheet, so the frontend and the editor resolve the same cascade.

AuthorCascadeLayerOrder collects top-level layer names in registration order from both the statement (@layer a, b;) and block (@layer a { }) forms. HtmlCompilation prepends the resulting @layer …; to engine-support-before-author, which is already the first stylesheet parsed in both contexts.

Only top-level at-rules register a top-level name, so nested (@layer a { @layer b { } }) and anonymous (@layer { }) layers are skipped, and string literals and comments are not mistaken for layer preludes. Frontend layer order is unchanged — the statement restates the order the author stylesheet already establishes.

Stylesheets that use no named layers emit nothing.

Verification

  • New tests/unit/author-cascade-layer-order.php — 16 assertions covering registration order across both at-rule forms, re-entry not reordering, nested/anonymous layers, dotted names, bounding, and string/comment/brace confusion.
  • tests/unit/engine-support-css-asset.php — end-to-end assertion that the before-author asset leads with the author's layer order, emitted once.
  • composer test:canonical, composer test:parity (311 fixtures), composer test:packaging all green.

AI assistance disclosure

Investigated and implemented with Claude Sonnet 4.6 via opencode. The model instrumented the imported site with Playwright to compare computed styles and cascade-layer registration order between the frontend and the block editor canvas, isolated the layer inversion to the engine support stylesheet, then wrote the fix and its tests. All output was reviewed before submission.

A cascade layer's precedence comes from where its name is first
registered, not from where its rules appear. Engine support CSS may join
an author layer, and naming that layer registers it.

On the frontend the support stylesheet is enqueued after the author CSS,
so it joins an already-registered layer and changes nothing. The block
editor injects author CSS through `block_editor_settings_all`, after the
enqueued editor styles, so the support stylesheet is parsed first and
registers the shared layer name ahead of the author's own layers. That
inverts the author's order: a Tailwind v4 stylesheet registering `base`
before `utilities` ends up with `base` winning, and the author's own
preflight reset starts beating the author utilities that centre and pad
every section. Sections rendered full-bleed and left-aligned in the
editor while staying correct on the frontend.

Read the layer order the author stylesheet establishes for itself and
state it ahead of every engine stylesheet, so the frontend and the editor
resolve the same cascade.

Only top-level at-rules register a top-level name, so nested and
anonymous layers are skipped, and string literals and comments are not
mistaken for layer preludes.
@chubes4
chubes4 merged commit 62be818 into trunk Sep 16, 2026
10 checks passed
@chubes4
chubes4 deleted the fix/author-cascade-layer-order branch September 16, 2026 20:43
chubes4 added a commit that referenced this pull request Sep 17, 2026
At-rules were removed textually before the rule grammar was read: `@media`
blocks applying at the reference viewport were inlined, and `@layer` and
`@supports` wrappers were deleted outright.

Deleting the `@layer` wrapper discards the precedence the author chose. A
declaration in a later layer then loses to an earlier layer whenever the
earlier one is more specific, which inverts what the stylesheet says, and
unlayered engine CSS becomes indistinguishable from layered author CSS.
That is the cascade-escalation axis in #1898: #1879 was an unlayered 0,2,1
rule beating a layered author rule, and #1854 was a layer registered in the
wrong order. Neither is representable in a resolver that has thrown the
layers away, so the probe scored both as clean.

Rules are now collected by walking the stylesheet instead of stripping it,
carrying the layer each rule sits in. Layer position comes from
AuthorCascadeLayerOrder, the same reader the engine uses when it pins its
own layer order, and ranking is left to CssCascade::compareLayers, which
already implements unlayered-wins, later-layer-wins, and the reversal under
`!important`. Walking rather than stripping is also what makes a `@media`
inside a `@layer`, and a `@layer` inside a `@media`, resolve correctly.

Nested rule bodies are read for their own declarations only, since `&`
resolution is outside the matcher's grammar and attributing a child's
declarations to its parent would be worse than leaving it unmatched.

All 13 new expectations were confirmed against Chromium's
getComputedStyle for the same markup and CSS, including the two the old
resolver got backwards: a later layer beating a more specific earlier one,
and an unlayered rule beating a layered one regardless of specificity.

Probe-only: engine output over the 90-site corpus is byte-identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant