[fix] Nest the runtime style sheet in a cascade layer - #2858
YevheniiKotyrlo wants to merge 1 commit into
Conversation
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.
|
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:
|
Before / afterA probe rendering three Stock 0.21.2 With this PR
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 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 Which build produced each frame is read rather than asserted: the capture counts the rules inside |


The problem
The rules
StyleSheetinjects 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:font-familycss-text-pvndmj text__ text-base-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, …css-text-pvndmj text__ font-mono text-baseui-monospace, SFMono-Regular, Menlo, …The second row is the tell.
font-monois an unlayered utility on the same element and wins; the layeredfont-sansin.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:
!importantis 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
createOrderedCSSStyleSheetconsumes exactly two members of what it is handed:cssRulessheetInsert'spositioninsertRule(cssText, position)insertRuleAtA
CSSLayerBlockRuleprovides both with identical semantics — it is a grouping rule, andinsertRuleAt's own comment already anticipates being handed one. So group markers, index arithmetic, selector de-duplication andgetTextContentare all untouched; the rules simply live one level deeper. Its parameter is now typed structurally to say so, rather than as a concreteCSSStyleSheet.dom-cascadeLayer-test.jsdrives 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:
insertRulemay accept the layer text and expose it as an ordinaryCSSStyleRule. 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.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.
resolveCascadeLayertherefore 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:
getSheet().textContentStyleSheetoption or build-time constant could keep the old precedence for one release.rnwis this PR's choice and yours to make. It is exported asStyleSheet.cascadeLayerName, because an application has to name it in an@layerstatement to position it — relying on style-element insertion order makes the ordering an implementation detail.@layeris 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 skippedjest --config ./configs/jest.config.node.js— 3 suites, 6 passedflow check— 0 errors (the structural type needs no suppression)eslintandprettier --check— cleanAppRegistry.getApplication().getStyleElement()Measured in a real browser against a built copy of this branch, on the same two screens before and after:
@layer rnwTextcomputedfont-familyTextcomputedcolorrgb(0, 0, 0)The same 321 rules move from unlayered to layered, and nothing else changes.
The
colorrow is worth noting:color: 'black'is still present inText'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.