Skip to content

[fix] Nest the runtime style sheet in a cascade layer - #2858

Open
YevheniiKotyrlo wants to merge 1 commit into
necolas:masterfrom
YevheniiKotyrlo:fix/layer-runtime-stylesheet
Open

YevheniiKotyrlo wants to merge 1 commit into
necolas:masterfrom
YevheniiKotyrlo:fix/layer-runtime-stylesheet

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown

The problem

The rules StyleSheet injects at runtime are framework defaults, but they are emitted unlayered. Under CSS Cascade 5 an unlayered declaration takes precedence over every layered one, regardless of specificity or layer order — so those defaults are not defaults at all, they are a ceiling.

An application that puts its own component rules in a cascade layer finds them silently outranked. There is no error, no malformed class, and no specificity fight available to win: the element simply paints the framework's value.

Measured in Chromium against an application whose component rules live in @layer components:

element class list computed font-family
css-text-pvndmj text__ text-base -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, …
css-text-pvndmj text__ font-mono text-base ui-monospace, SFMono-Regular, Menlo, …

The second row is the tell. font-mono is an unlayered utility on the same element and wins; the layered font-sans in .text__ loses. The author's stylesheet reaches the element perfectly — it is the layer that loses.

The declarations that ceiling holds include the two an application most often needs to set:

// src/exports/Text/index.js
const textStyle = {
  
  color: 'black',
  font: '14px System',   // font-family AND font-size, on every Text};

!important is the only author mechanism that reaches past an unlayered rule, and it is not available to a generated stylesheet: an important declaration in a lower layer then beats every higher one, inverting the whole model.

The fix

Nest the runtime sheet's rules in a cascade layer, so the defaults are defaults again. Application styles stay unlayered and continue to win — which is the contract these rules were always meant to have.

Why this does not disturb the ordered stylesheet

createOrderedCSSStyleSheet consumes exactly two members of what it is handed:

member used by
cssRules the hydration walk, and sheetInsert's position
insertRule(cssText, position) insertRuleAt

A CSSLayerBlockRule provides both with identical semantics — it is a grouping rule, and insertRuleAt's own comment already anticipates being handed one. So group markers, index arithmetic, selector de-duplication and getTextContent are all untouched; the rules simply live one level deeper. Its parameter is now typed structurally to say so, rather than as a concrete CSSStyleSheet.

dom-cascadeLayer-test.js drives the ordered sheet through a layer-shaped container directly, covering insertion, group ordering, de-duplication and hydration.

Support is feature-tested, not assumed

An engine without cascade-layer support fails silently in two directions, and I hit both:

  1. insertRule may accept the layer text and expose it as an ordinary CSSStyleRule. That rule then stays in the sheet, where the hydration walk reads it as a rule belonging to a group that was never opened — TypeError: Cannot read properties of undefined (reading 'rules'), at module import.
  2. A style element whose text content is wrapped in a layer block may fail to parse in full, leaving no sheet at all.

Both are reachable in this repo's own test environment (jsdom 20 does not implement cascade layers), so a naive version of this change fails the existing suite. resolveCascadeLayer therefore probes and removes the probe rule again when the result is not a layer, leaving an unsupported engine exactly as it was.

Server rendering

getTextContent() is wrapped too, so a server-rendered sheet carries the same precedence as the runtime one and hydration has a layer to read the rules back out of.

Without this the fix is defeated in every server-rendered application: the client finds no layer in the hydrated sheet, appends an empty one, hydrates zero rules — so every server-rendered rule stays unlayered and is duplicated into the layer.

An engine that cannot parse the layer block drops it and the runtime re-inserts the rules unlayered as the application renders, which is today's behaviour.

Breaking change

This is behaviour-breaking by construction, and belongs in a major:

question position
Who breaks? an application whose own rules are layered and which relies on the framework's defaults still winning. That combination is the defect, so the break is the point — but it is a break.
getSheet().textContent now carries the layer wrapper. Snapshot-asserting consumers will see it.
Opt-out? worth offering. A StyleSheet option or build-time constant could keep the old precedence for one release.
Layer name rnw is this PR's choice and yours to make. It is exported as StyleSheet.cascadeLayerName, because an application has to name it in an @layer statement to position it — relying on style-element insertion order makes the ordering an implementation detail.
Browser support @layer is in every evergreen engine. The feature test covers the rest, so no browserslist entry has to move.

Verification

  • jest --config ./configs/jest.config.js — 48 suites, 742 passed, 6 skipped
  • jest --config ./configs/jest.config.node.js — 3 suites, 6 passed
  • flow check — 0 errors (the structural type needs no suppression)
  • eslint and prettier --check — clean
  • 6 snapshots updated, all of them the intended precedence change, including AppRegistry.getApplication().getStyleElement()

Measured in a real browser against a built copy of this branch, on the same two screens before and after:

before after
rules inside @layer rnw 0 321
unlayered rules in the document 2122 1801
layered rules in the document 1793 2114
Text computed font-family system stack the application's own face
Text computed color rgb(0, 0, 0) the application's own token

The same 321 rules move from unlayered to layered, and nothing else changes.

The color row is worth noting: color: 'black' is still present in Text's base style. Once the sheet is layered it behaves as the React Native parity default it was always meant to be, so no declaration had to be removed from the framework's own defaults to fix it.

The rules StyleSheet injects are framework defaults, but they are emitted
unlayered — and under CSS Cascade 5 an unlayered declaration takes precedence
over every layered one, regardless of specificity or layer order. That makes
them a ceiling rather than a default: an application that puts its own rules in
a cascade layer finds them silently outranked, with no error and no specificity
fight available to win. `!important` is the only author mechanism that reaches
past an unlayered rule, and using it inverts the model, since an important
declaration in a lower layer then beats every higher one.

Nesting the sheet's rules in a layer makes the defaults defaults again.
Application styles stay unlayered and continue to win.

`createOrderedCSSStyleSheet` consumes only `cssRules` and `insertRule`, both of
which a CSSLayerBlockRule provides with identical semantics, so grouping,
ordering, de-duplication and hydration are unchanged one level deeper. Its
parameter is now typed structurally to say so.

Support is feature-tested rather than assumed, because an engine without it
fails silently in two directions: `insertRule` may accept the layer text and
expose it as an ordinary style rule, which then corrupts the sheet's group
hydration, and a style element whose text is wrapped in a layer block may fail
to parse in full. Where cascade layers are unavailable the sheet is left exactly
as it was.

The serialized text `getSheet()` returns is wrapped as well, so a server
rendered sheet carries the same precedence as the runtime one and hydration has
a layer to read the rules back out of. Emitted unlayered it would outrank the
application's own layered rules until hydration replaced it, and every
server-rendered rule would then be duplicated into an empty layer.

The layer name is exported as `StyleSheet.cascadeLayerName`, so an application
can position it with an `@layer` statement rather than depending on the order
style elements happen to be inserted in.
@codesandbox-ci

Copy link
Copy Markdown

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit ecf23ce:

Sandbox Source
react-native-web-examples Configuration

@YevheniiKotyrlo

Copy link
Copy Markdown
Author

Before / after

A probe rendering three Views that carry one author declaration written three ways — inside @layer components, unlayered, and absent — over react-native-web's own View. Both frames drive the same page. The only input that differs is which react-native-web dist the bundle resolved to.

Stock 0.21.2

stock 0.21.2 — the layered declaration paints nothing

With this PR

with this PR — the layered declaration wins

the author's declaration stock 0.21.2 this PR
inside @layer components rgba(0, 0, 0, 0) — the framework's own fill rgb(200, 30, 74)
the identical declaration, unlayered rgb(200, 30, 74) rgb(200, 30, 74)
absent — the framework default paints it rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)
rules inside @layer rnw 0 345

Row two is what makes this a cascade verdict rather than a specificity one: the same declaration wins the moment it leaves the layer, so the author stylesheet is reaching the element perfectly on both builds. Row three is unchanged, so nothing was removed from the framework's own defaults to get there.

The subject is a background-color deliberately. It is declared by the View base and by nothing in my own component kit, so the layer is the only variable; a text property would have been contested by two author rules and moved between builds for a second reason.

The labels change typeface between the two frames, and that is the same defect showing up a second time. They are my kit's text component, whose base rule lives in @layer components and sets font-family. On stock it loses to the unlayered font: '14px System'; on this branch it wins. That is the font-family row from the top of this PR, in a picture rather than a table.

Which build produced each frame is read rather than asserted: the capture counts the rules inside @layer rnw in the document it is about to photograph, and refuses to shoot when that reading and the variant disagree. Swapping node_modules does not invalidate a bundler's transform cache, which is how I nearly filed a pixel-identical pair as a before/after once already.

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