From e0d1610a3dd65ec423a26d1b291b6523cdeeb94c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 09:57:04 +0300 Subject: [PATCH 1/2] [fix] Nest the runtime style sheet in a cascade layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../AppRegistry/__tests__/index-test.node.js | 12 +- .../__tests__/dom-cascadeLayer-test.js | 215 ++++++++++++++++++ .../__tests__/dom-createCSSStyleSheet-test.js | 69 ++++++ .../exports/StyleSheet/__tests__/dom-test.js | 29 +++ .../StyleSheet/__tests__/dom-test.node.js | 8 +- .../StyleSheet/__tests__/index-test.js | 4 +- .../exports/StyleSheet/dom/cascadeLayer.js | 97 ++++++++ .../StyleSheet/dom/createCSSStyleSheet.js | 21 +- .../dom/createOrderedCSSStyleSheet.js | 13 +- .../src/exports/StyleSheet/dom/index.js | 9 +- .../src/exports/StyleSheet/index.js | 6 + 11 files changed, 466 insertions(+), 17 deletions(-) create mode 100644 packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js create mode 100644 packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createCSSStyleSheet-test.js create mode 100644 packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js diff --git a/packages/react-native-web/src/exports/AppRegistry/__tests__/index-test.node.js b/packages/react-native-web/src/exports/AppRegistry/__tests__/index-test.node.js index 082bdd1fe5..fdc609c0a4 100644 --- a/packages/react-native-web/src/exports/AppRegistry/__tests__/index-test.node.js +++ b/packages/react-native-web/src/exports/AppRegistry/__tests__/index-test.node.js @@ -39,7 +39,7 @@ describe('AppRegistry', () => { `); expect(styleElement).toMatchInlineSnapshot(` - "" + .r-wordWrap-1iln25a{word-wrap:normal;}}" `); }); @@ -98,7 +98,7 @@ describe('AppRegistry', () => { AppRegistry.registerComponent('App', () => RootComponent); const first = getApplicationStyles('App'); expect(first).toMatchInlineSnapshot(` - "[stylesheet-group="0"]{} + "@layer rnw{[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} @@ -133,7 +133,7 @@ describe('AppRegistry', () => { .r-userSelect-1xnzce8{-moz-user-select:text;-webkit-user-select:text;user-select:text;} .r-userSelect-lrvibr{-moz-user-select:none;-webkit-user-select:none;user-select:none;} .r-whiteSpace-3s2u2q{white-space:nowrap;} - .r-wordWrap-1iln25a{word-wrap:normal;}" + .r-wordWrap-1iln25a{word-wrap:normal;}}" `); // Second render "AlternativeComponent" @@ -148,7 +148,7 @@ describe('AppRegistry', () => { ); const second = getApplicationStyles('AlternativeApp'); expect(second).toMatchInlineSnapshot(` - "[stylesheet-group="0"]{} + "@layer rnw{[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} @@ -185,7 +185,7 @@ describe('AppRegistry', () => { .r-userSelect-1xnzce8{-moz-user-select:text;-webkit-user-select:text;user-select:text;} .r-userSelect-lrvibr{-moz-user-select:none;-webkit-user-select:none;user-select:none;} .r-whiteSpace-3s2u2q{white-space:nowrap;} - .r-wordWrap-1iln25a{word-wrap:normal;}" + .r-wordWrap-1iln25a{word-wrap:normal;}}" `); // Third render "RootComponent" again diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js new file mode 100644 index 0000000000..9521e960ef --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js @@ -0,0 +1,215 @@ +/** + * Copyright (c) Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + CASCADE_LAYER_NAME, + isCascadeLayerRule, + resolveCascadeLayer, + wrapInCascadeLayer +} from '../dom/cascadeLayer'; +import createOrderedCSSStyleSheet from '../dom/createOrderedCSSStyleSheet'; + +/** + * A rule container with the two members this module consumes. The CSSOM in this test environment + * does not report a cascade layer's name, so the layered path is driven through these instead: a fake + * that reports the shape a CSSLayerBlockRule has, and one that reports the shape an engine without + * support produces. + */ +const createRuleContainer = ({ name } = {}) => { + const cssRules = []; + const container = { + cssRules, + insertRule(cssText, position) { + const selectorText = cssText.split('{')[0].trim(); + const nested = + selectorText === `@layer ${CASCADE_LAYER_NAME}` + ? // A supporting engine exposes the block as a nested container; one without support + // exposes it as an ordinary style rule, which has no 'name' and no 'insertRule'. + createRuleContainer({ + name: name === undefined ? CASCADE_LAYER_NAME : name + }) + : { cssText, selectorText }; + cssRules.splice(position, 0, nested); + return position; + }, + deleteRule(position) { + cssRules.splice(position, 1); + } + }; + if (name != null) { + container.name = name; + } + return container; +}; + +/** An engine that parses the layer text as an ordinary style rule — no 'name', no 'insertRule'. */ +const createLayerBlindContainer = () => { + const cssRules = []; + return { + cssRules, + insertRule(cssText, position) { + cssRules.splice(position, 0, { + cssText, + selectorText: cssText.split('{')[0].trim() + }); + return position; + }, + deleteRule(position) { + cssRules.splice(position, 1); + } + }; +}; + +/** An engine that rejects the layer text outright. */ +const createRejectingContainer = () => ({ + cssRules: { length: 0 }, + insertRule() { + throw new Error('unsupported rule'); + }, + deleteRule() { + throw new Error('nothing to delete'); + } +}); + +describe('wrapInCascadeLayer', () => { + test('wraps css text in the layer block', () => { + expect(wrapInCascadeLayer('.a{opacity:1;}')).toMatchInlineSnapshot( + `"@layer rnw{.a{opacity:1;}}"` + ); + }); + + test('wraps empty text', () => { + expect(wrapInCascadeLayer('')).toMatchInlineSnapshot(`"@layer rnw{}"`); + }); + + test('is deterministic', () => { + expect(wrapInCascadeLayer('.a{}')).toBe(wrapInCascadeLayer('.a{}')); + }); + + test('is not idempotent, so a double wrap is visible rather than silent', () => { + // Each call nests one more layer block. The sheet's text is wrapped at exactly one seam, and a + // second wrap would change the output rather than being absorbed. + expect(wrapInCascadeLayer(wrapInCascadeLayer('.a{}'))).not.toBe( + wrapInCascadeLayer('.a{}') + ); + }); +}); + +describe('isCascadeLayerRule', () => { + test.each([ + ['null', null], + ['undefined', undefined], + ['a style rule', { cssText: '.a{}', selectorText: '.a' }], + ['a keyframes rule', { name: 'spin' }], + ['a differently named layer', { name: 'other', insertRule() {} }], + ['a layer block that reports no name', { insertRule() {} }], + ['a layer name without insertRule', { name: CASCADE_LAYER_NAME }] + ])('rejects %s', (_label, rule) => { + expect(isCascadeLayerRule(rule)).toBe(false); + }); + + test('accepts a layer block rule', () => { + expect( + isCascadeLayerRule({ name: CASCADE_LAYER_NAME, insertRule() {} }) + ).toBe(true); + }); +}); + +describe('resolveCascadeLayer', () => { + test('returns null for a missing sheet', () => { + expect(resolveCascadeLayer(null)).toBe(null); + expect(resolveCascadeLayer(undefined)).toBe(null); + }); + + test('creates the layer where the engine supports it', () => { + const sheet = createRuleContainer(); + const layer = resolveCascadeLayer(sheet); + expect(layer).not.toBe(null); + expect(isCascadeLayerRule(layer)).toBe(true); + expect(sheet.cssRules).toHaveLength(1); + }); + + test('is idempotent — a second call adopts the existing layer', () => { + const sheet = createRuleContainer(); + const first = resolveCascadeLayer(sheet); + const second = resolveCascadeLayer(sheet); + expect(second).toBe(first); + expect(sheet.cssRules).toHaveLength(1); + }); + + test('adopts a layer already present in a hydrated sheet', () => { + const sheet = createRuleContainer(); + sheet.insertRule(`@layer ${CASCADE_LAYER_NAME}{}`, 0); + const existing = sheet.cssRules[0]; + expect(resolveCascadeLayer(sheet)).toBe(existing); + expect(sheet.cssRules).toHaveLength(1); + }); + + test('leaves no rule behind when the engine cannot express a layer', () => { + // The regression this guards: a rule accepted but not exposed as a layer stays in the sheet, + // where the ordered sheet reads it as a rule belonging to a group that was never opened. + const sheet = createLayerBlindContainer(); + expect(resolveCascadeLayer(sheet)).toBe(null); + expect(sheet.cssRules).toHaveLength(0); + }); + + test('leaves the sheet untouched when the engine rejects the rule', () => { + const sheet = createRejectingContainer(); + expect(resolveCascadeLayer(sheet)).toBe(null); + expect(sheet.cssRules.length).toBe(0); + }); +}); + +describe('createOrderedCSSStyleSheet through a cascade layer', () => { + // The claim the layered design rests on: a layer block exposes the same two members the ordered + // sheet consumes, so grouping, ordering and de-duplication are unchanged one level deeper. + test('writes rules into the layer', () => { + const layer = resolveCascadeLayer(createRuleContainer()); + const sheet = createOrderedCSSStyleSheet(layer); + sheet.insert('.a{opacity:1;}', 0); + expect(layer.cssRules.map((rule) => rule.cssText)).toEqual([ + '[stylesheet-group="0"]{}', + '.a{opacity:1;}' + ]); + }); + + test('preserves group ordering inside the layer', () => { + const layer = resolveCascadeLayer(createRuleContainer()); + const sheet = createOrderedCSSStyleSheet(layer); + sheet.insert('.c{}', 2); + sheet.insert('.a{}', 0); + expect(sheet.getTextContent()).toMatchInlineSnapshot(` + "[stylesheet-group="0"]{} + .a{} + [stylesheet-group="2"]{} + .c{}" + `); + }); + + test('de-duplicates inside the layer', () => { + const layer = resolveCascadeLayer(createRuleContainer()); + const sheet = createOrderedCSSStyleSheet(layer); + sheet.insert('.a{}', 0); + sheet.insert('.a{}', 0); + expect(layer.cssRules).toHaveLength(2); + }); + + test('hydrates from rules already inside the layer', () => { + // The server-rendered path: the layer arrives populated, and the ordered sheet must read its + // groups back out rather than starting empty and re-inserting everything. + const layer = resolveCascadeLayer(createRuleContainer()); + layer.insertRule('[stylesheet-group="0"]{}', 0); + layer.insertRule('.a{opacity:1;}', 1); + const sheet = createOrderedCSSStyleSheet(layer); + sheet.insert('.a{opacity:1;}', 0); + expect(layer.cssRules).toHaveLength(2); + expect(sheet.getTextContent()).toMatchInlineSnapshot(` + "[stylesheet-group="0"]{} + .a{opacity:1;}" + `); + }); +}); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createCSSStyleSheet-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createCSSStyleSheet-test.js new file mode 100644 index 0000000000..7cdbd11616 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createCSSStyleSheet-test.js @@ -0,0 +1,69 @@ +/** + * Copyright (c) Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import createCSSStyleSheet from '../dom/createCSSStyleSheet'; +import createOrderedCSSStyleSheet from '../dom/createOrderedCSSStyleSheet'; + +/** + * The CSSOM in this test environment does not report a cascade layer's name, so these tests pin the + * fallback: an engine without support must behave exactly as it did before layers were introduced. + * The layered path is covered by 'dom-cascadeLayer-test.js', which drives the ordered sheet through + * a rule container of the shape a CSSLayerBlockRule has. + */ +describe('createCSSStyleSheet', () => { + test('returns a container the ordered style sheet can hydrate', () => { + // An engine that parses the layer text as an ordinary style rule would leave that rule in the + // sheet, where hydration reads it as a rule belonging to a group that was never opened. + const container = createCSSStyleSheet('hydratable-sheet'); + expect(() => createOrderedCSSStyleSheet(container)).not.toThrow(); + }); + + test('leaves no rule behind when the engine cannot express a cascade layer', () => { + const container = createCSSStyleSheet('empty-sheet'); + expect(container.cssRules).toHaveLength(0); + }); + + test('inserted rules are readable back from the container', () => { + const sheet = createOrderedCSSStyleSheet( + createCSSStyleSheet('readable-sheet') + ); + sheet.insert('.a{opacity:1;}', 0); + expect(sheet.getTextContent()).toContain('.a{opacity:1;}'); + }); + + test('adopts the existing element when the id is already present', () => { + const first = createCSSStyleSheet('shared-sheet'); + const second = createCSSStyleSheet('shared-sheet'); + expect(second).toBe(first); + }); + + describe('with text content', () => { + // The text content path clones an existing sheet into a second root. Wrapping that text in a + // layer block an engine cannot parse invalidates the whole style element, which leaves no sheet + // at all rather than an unlayered one. + test('produces a usable sheet', () => { + const container = createCSSStyleSheet( + 'cloned-sheet', + document, + '[stylesheet-group="0"]{}\n.a{opacity:1;}' + ); + expect(container).not.toBe(null); + expect(container.cssRules.length).toBeGreaterThan(0); + }); + + test('the cloned rules hydrate into the ordered sheet', () => { + const sheet = createOrderedCSSStyleSheet( + createCSSStyleSheet( + 'cloned-hydrated-sheet', + document, + '[stylesheet-group="0"]{}\n.a{opacity:1;}' + ) + ); + expect(sheet.getTextContent()).toContain('.a'); + }); + }); +}); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.js index 533b2b8c7c..79a7574803 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import { CASCADE_LAYER_NAME } from '../dom/cascadeLayer'; import { createSheet } from '../dom'; describe('createSheet', () => { @@ -57,4 +58,32 @@ describe('createSheet', () => { sheet.insert('.test-shadow { opacity: 0 }', 3); expect(shadowSheet.getTextContent().includes('test-shadow')).toBe(true); }); + + describe('#getTextContent', () => { + // Serialized text is delivered in the HTML document and read back by the next client that + // hydrates it. Emitted unlayered it would outrank the application's own layered rules until + // hydration replaced it, and hydration would find no layer to read the rules back out of — + // so every server-rendered rule would stay unlayered and be duplicated into an empty layer. + test('serializes into the cascade layer', () => { + const sheet = createSheet(); + const textContent = sheet.getTextContent(); + expect(textContent.startsWith(`@layer ${CASCADE_LAYER_NAME}{`)).toBe( + true + ); + expect(textContent.endsWith('}')).toBe(true); + }); + + test('nests the rules rather than placing them beside an empty layer', () => { + const sheet = createSheet(); + sheet.insert('.test-serialized { opacity: 1 }', 3); + const textContent = sheet.getTextContent(); + expect(textContent).toContain('.test-serialized'); + expect(textContent).not.toContain(`@layer ${CASCADE_LAYER_NAME}{}`); + }); + + test('is deterministic across calls', () => { + const sheet = createSheet(); + expect(sheet.getTextContent()).toBe(sheet.getTextContent()); + }); + }); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.node.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.node.js index 2ad1015dd3..5e8de7a25a 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.node.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.node.js @@ -13,22 +13,22 @@ describe('createSheet', () => { expect(typeof sheet.insert).toBe('function'); expect(sheet.id).toMatchInlineSnapshot(`"react-native-stylesheet"`); expect(sheet.getTextContent()).toMatchInlineSnapshot(` - "[stylesheet-group="0"]{} + "@layer rnw{[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} - input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}" + input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}}" `); sheet.insert('.test { opacity: 0 }', 1); expect(sheet.getTextContent()).toMatchInlineSnapshot(` - "[stylesheet-group="0"]{} + "@layer rnw{[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;} [stylesheet-group="1"]{} - .test { opacity: 0 }" + .test { opacity: 0 }}" `); }); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js index 888de2624e..90598c845c 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js @@ -13,7 +13,7 @@ describe('StyleSheet', () => { expect(StyleSheet.getSheet()).toMatchInlineSnapshot(` { "id": "react-native-stylesheet", - "textContent": "[stylesheet-group="0"]{} + "textContent": "@layer rnw{[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} @@ -23,7 +23,7 @@ describe('StyleSheet', () => { .r-left-1d2f490{left:0px;} .r-position-u8s1d{position:absolute;} .r-right-zchlnj{right:0px;} - .r-top-ipm5af{top:0px;}", + .r-top-ipm5af{top:0px;}}", } `); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js b/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js new file mode 100644 index 0000000000..590d8752f6 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js @@ -0,0 +1,97 @@ +/** + * Copyright (c) Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + */ + +import type { CSSRuleContainer } from './createOrderedCSSStyleSheet'; + +/** + * The cascade layer the runtime style sheet's rules belong to. + * + * The rules this sheet holds are framework defaults, but an unlayered declaration takes precedence + * over every layered one regardless of specificity or layer order. Emitting them unlayered 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. + * + * Nesting them in a layer restores the intended precedence. Application styles stay unlayered and + * continue to win, and an application that uses layers can position this one by name. + */ +export const CASCADE_LAYER_NAME = 'rnw'; + +const layerBlockPrefix = `@layer ${CASCADE_LAYER_NAME}{`; + +/** + * Serialize css text as the contents of the cascade layer. Pure, total, and deterministic: the + * single place the layer's syntax is written, so the runtime sheet and the extracted text cannot + * disagree about what the layer is called or how it is opened. + */ +export function wrapInCascadeLayer(cssText: string): string { + return `${layerBlockPrefix}${cssText}}`; +} + +/** + * A CSSLayerBlockRule is the only rule type exposing both 'name' and 'insertRule'. + * CSSKeyframesRule has a 'name' and no 'insertRule', and the legacy numeric 'type' reports 0 for + * every rule added after that enum was frozen, so it distinguishes nothing. + */ +export function isCascadeLayerRule(rule: ?Object): boolean { + return ( + rule != null && + rule.name === CASCADE_LAYER_NAME && + typeof rule.insertRule === 'function' + ); +} + +/** + * The sheet's own cascade layer block, or null where the CSSOM cannot express one. + * + * Support is measured 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, and + * a style element whose text is wrapped in a layer block may fail to parse in full. A rule that + * turns out not to be a layer is removed again, so an unsupported engine is left exactly as it was. + */ +export function resolveCascadeLayer( + sheet: ?CSSRuleContainer +): ?CSSRuleContainer { + if (sheet == null) { + return null; + } + const rules = sheet.cssRules; + for (let i = 0; i < rules.length; i += 1) { + if (isCascadeLayerRule(rules[i])) { + return rules[i]; + } + } + const position = rules.length; + try { + sheet.insertRule(`${layerBlockPrefix}}`, position); + } catch (e) { + return null; + } + if (isCascadeLayerRule(rules[position])) { + return rules[position]; + } + sheet.deleteRule(position); + return null; +} + +/** + * Whether this document's CSSOM implements cascade layers. Measured against a detached probe so + * the answer is available before a style element's text is composed, and leaves the document as + * it was found. + */ +export function documentSupportsCascadeLayer(): boolean { + const head = document.head; + if (head == null) { + return false; + } + const probe = document.createElement('style'); + head.appendChild(probe); + const supported = resolveCascadeLayer(probe.sheet) != null; + head.removeChild(probe); + return supported; +} diff --git a/packages/react-native-web/src/exports/StyleSheet/dom/createCSSStyleSheet.js b/packages/react-native-web/src/exports/StyleSheet/dom/createCSSStyleSheet.js index 93b961a14d..6e0e557ab5 100644 --- a/packages/react-native-web/src/exports/StyleSheet/dom/createCSSStyleSheet.js +++ b/packages/react-native-web/src/exports/StyleSheet/dom/createCSSStyleSheet.js @@ -6,6 +6,13 @@ * * @flow strict-local */ + +import type { CSSRuleContainer } from './createOrderedCSSStyleSheet'; +import { + documentSupportsCascadeLayer, + resolveCascadeLayer, + wrapInCascadeLayer +} from './cascadeLayer'; import canUseDOM from '../../../modules/canUseDom'; // $FlowFixMe: HTMLStyleElement is incorrectly typed - https://github.com/facebook/flow/issues/2696 @@ -13,7 +20,7 @@ export default function createCSSStyleSheet( id: string, rootNode?: Document | ShadowRoot, textContent?: string -): ?CSSStyleSheet { +): ?CSSRuleContainer { if (canUseDOM) { const root = rootNode != null ? rootNode : document; let element = root.getElementById(id); @@ -21,7 +28,13 @@ export default function createCSSStyleSheet( element = document.createElement('style'); element.setAttribute('id', id); if (typeof textContent === 'string') { - element.appendChild(document.createTextNode(textContent)); + element.appendChild( + document.createTextNode( + documentSupportsCascadeLayer() + ? wrapInCascadeLayer(textContent) + : textContent + ) + ); } if (root instanceof ShadowRoot) { root.insertBefore(element, root.firstChild); @@ -33,7 +46,9 @@ export default function createCSSStyleSheet( } } // $FlowFixMe: HTMLElement is incorrectly typed - return element.sheet; + const sheet: ?CSSRuleContainer = element.sheet; + const layer = resolveCascadeLayer(sheet); + return layer != null ? layer : sheet; } else { return null; } diff --git a/packages/react-native-web/src/exports/StyleSheet/dom/createOrderedCSSStyleSheet.js b/packages/react-native-web/src/exports/StyleSheet/dom/createOrderedCSSStyleSheet.js index 3999ca08be..2f5ba38c20 100644 --- a/packages/react-native-web/src/exports/StyleSheet/dom/createOrderedCSSStyleSheet.js +++ b/packages/react-native-web/src/exports/StyleSheet/dom/createOrderedCSSStyleSheet.js @@ -10,6 +10,17 @@ type Groups = { [key: number]: { start: ?number, rules: Array } }; type Selectors = { [key: string]: boolean }; +/** + * The rule container this module reads and writes: a CSSStyleSheet, or any grouping rule exposing + * the same members. Typed structurally because a CSSLayerBlockRule satisfies it identically, which + * is what allows the sheet's rules to be nested in a cascade layer. + */ +export type CSSRuleContainer = interface { + +cssRules: interface { +length: number, [index: number]: Object }, + insertRule(cssText: string, position: number): number, + deleteRule(position: number): void +}; + export type OrderedCSSStyleSheet = {| getTextContent: () => string, insert: (cssText: string, groupValue: number) => void @@ -33,7 +44,7 @@ const slice = Array.prototype.slice; * https://gist.github.com/necolas/aa0c37846ad6bd3b05b727b959e82674 */ export default function createOrderedCSSStyleSheet( - sheet: ?CSSStyleSheet + sheet: ?CSSRuleContainer ): OrderedCSSStyleSheet { const groups: Groups = {}; const selectors: Selectors = {}; diff --git a/packages/react-native-web/src/exports/StyleSheet/dom/index.js b/packages/react-native-web/src/exports/StyleSheet/dom/index.js index c55fd5a382..fb3056d802 100644 --- a/packages/react-native-web/src/exports/StyleSheet/dom/index.js +++ b/packages/react-native-web/src/exports/StyleSheet/dom/index.js @@ -8,6 +8,7 @@ */ import type { OrderedCSSStyleSheet } from './createOrderedCSSStyleSheet'; +import { wrapInCascadeLayer } from './cascadeLayer'; import canUseDOM from '../../../modules/canUseDom'; import createCSSStyleSheet from './createCSSStyleSheet'; import createOrderedCSSStyleSheet from './createOrderedCSSStyleSheet'; @@ -77,8 +78,14 @@ export function createSheet( } return { + /** + * The style sheet serialized for delivery in an HTML document. Wrapped in the cascade layer so + * server-rendered rules carry the same precedence the runtime sheet gives them: emitted + * unlayered, they would outrank the application's own layered rules until hydration replaced + * them, and hydration would find no layer to read the rules back out of. + */ getTextContent() { - return sheet.getTextContent(); + return wrapInCascadeLayer(sheet.getTextContent()); }, id, insert(cssText: string, groupValue: number) { diff --git a/packages/react-native-web/src/exports/StyleSheet/index.js b/packages/react-native-web/src/exports/StyleSheet/index.js index a13393665b..05d2826c16 100644 --- a/packages/react-native-web/src/exports/StyleSheet/index.js +++ b/packages/react-native-web/src/exports/StyleSheet/index.js @@ -8,6 +8,7 @@ */ import { atomic, classic, inline } from './compiler'; +import { CASCADE_LAYER_NAME } from './dom/cascadeLayer'; import { createSheet } from './dom'; import { localizeStyle } from 'styleq/transform-localize-style'; import { preprocess } from './preprocess'; @@ -169,6 +170,10 @@ function StyleSheet(styles: any, options?: Options = {}): StyleProps { StyleSheet.absoluteFill = absoluteFill; StyleSheet.absoluteFillObject = absoluteFillObject; +// The cascade layer the runtime style sheet's rules belong to. Exported so an application can +// position it against its own layers with an `@layer` statement, rather than depending on the +// order style elements happen to be inserted in. +StyleSheet.cascadeLayerName = CASCADE_LAYER_NAME; StyleSheet.create = create; StyleSheet.compose = compose; StyleSheet.flatten = flatten; @@ -185,6 +190,7 @@ export type IStyleSheet = { (styles: $ReadOnlyArray, options?: Options): StyleProps, absoluteFill: Object, absoluteFillObject: Object, + cascadeLayerName: string, create: typeof create, compose: typeof compose, flatten: typeof flatten, From 1d3cef2b434a1e7ee28aea493446921e5ae5b948 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 14:01:29 +0300 Subject: [PATCH 2/2] Nest the runtime style sheet in a cascade layer only when asked to Nesting the sheet in a layer of its own changed precedence for every app: an app's unlayered rules, such as a global reset, would then override every StyleSheet style. The rules are now nested only on request: StyleSheet.setCascadeLayer(name) nests the rules already inserted, every later rule and the server-rendered sheet, and a page can instead declare the layer in the style sheet's element, e.g. . Every other app is unchanged. A copy of the sheet in another root is nested in the same layer, and hydration leaves rules the page declared before the first group alone instead of throwing. --- .../src/pages/docs/apis/style-sheet.md | 4 + .../src/pages/docs/concepts/styling.md | 23 ++ .../AppRegistry/__tests__/index-test.node.js | 31 ++- .../__tests__/dom-cascadeLayer-test.js | 235 +++------------- .../__tests__/dom-createCSSStyleSheet-test.js | 69 ----- .../dom-createOrderedCSSStyleSheet-test.js | 16 ++ .../exports/StyleSheet/__tests__/dom-test.js | 261 ++++++++++++++++-- .../StyleSheet/__tests__/dom-test.node.js | 26 +- .../StyleSheet/__tests__/index-test.js | 4 +- .../exports/StyleSheet/dom/cascadeLayer.js | 91 ++---- .../StyleSheet/dom/createCSSStyleSheet.js | 21 +- .../dom/createOrderedCSSStyleSheet.js | 3 +- .../src/exports/StyleSheet/dom/index.js | 169 +++++++++--- .../src/exports/StyleSheet/index.js | 12 +- 14 files changed, 548 insertions(+), 417 deletions(-) delete mode 100644 packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createCSSStyleSheet-test.js diff --git a/packages/react-native-web-docs/src/pages/docs/apis/style-sheet.md b/packages/react-native-web-docs/src/pages/docs/apis/style-sheet.md index 3b15ed7191..b947ce5397 100644 --- a/packages/react-native-web-docs/src/pages/docs/apis/style-sheet.md +++ b/packages/react-native-web-docs/src/pages/docs/apis/style-sheet.md @@ -50,3 +50,7 @@ Define style objects. Each key of the object passed to `create` must define a st {% call macro.prop('flatten', '(styles: Style) => Object') %} Flatten an array of styles into a single style object. **This is not recommended as it is not compatible with static extraction of styles to CSS.** {% endcall %} + +{% call macro.prop('setCascadeLayer', '(name: string) => void') %} +A web-only method that nests the generated CSS in the [cascade layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) `name`, including the CSS rendered on the server. Call it before rendering. See [Cascade layers]({{ '/docs/styling/#cascade-layers' | url }}). +{% endcall %} diff --git a/packages/react-native-web-docs/src/pages/docs/concepts/styling.md b/packages/react-native-web-docs/src/pages/docs/concepts/styling.md index 5917781f6b..4e5f8f2ef1 100644 --- a/packages/react-native-web-docs/src/pages/docs/concepts/styling.md +++ b/packages/react-native-web-docs/src/pages/docs/concepts/styling.md @@ -279,3 +279,26 @@ This ensures that CSS order doesn't impact rendering and CSS rules are efficient Class names are deterministic, which means that the resulting CSS and HTML is consistent across builds – important for large apps using code-splitting and deploying incremental updates. At runtime registered styles are resolved to DOM style props and memoized. Any dynamic styles that contain declarations previously registered as static styles can also be converted to CSS class names. Otherwise, they render as inline styles. + +### Cascade layers + +The generated CSS is not in a [cascade layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer), so it takes precedence over every rule an app puts in one. An app that orders its own CSS in layers can nest the generated CSS in a layer and position it among them: + +```js +StyleSheet.setCascadeLayer('rnw'); +``` + +```css +@layer rnw, base, components, utilities; +``` + +The CSS rendered on the server is nested in the same layer. Alternatively, a page can declare the layer in the style sheet's element before {{ site.name }} loads: + +```html + +``` + +CSS outside any layer, such as a global reset, then takes precedence over every style defined with `StyleSheet`. diff --git a/packages/react-native-web/src/exports/AppRegistry/__tests__/index-test.node.js b/packages/react-native-web/src/exports/AppRegistry/__tests__/index-test.node.js index fdc609c0a4..9fe146fa71 100644 --- a/packages/react-native-web/src/exports/AppRegistry/__tests__/index-test.node.js +++ b/packages/react-native-web/src/exports/AppRegistry/__tests__/index-test.node.js @@ -39,7 +39,7 @@ describe('AppRegistry', () => { `); expect(styleElement).toMatchInlineSnapshot(` - "" + .r-wordWrap-1iln25a{word-wrap:normal;}" `); }); @@ -98,7 +98,7 @@ describe('AppRegistry', () => { AppRegistry.registerComponent('App', () => RootComponent); const first = getApplicationStyles('App'); expect(first).toMatchInlineSnapshot(` - "@layer rnw{[stylesheet-group="0"]{} + "[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} @@ -133,7 +133,7 @@ describe('AppRegistry', () => { .r-userSelect-1xnzce8{-moz-user-select:text;-webkit-user-select:text;user-select:text;} .r-userSelect-lrvibr{-moz-user-select:none;-webkit-user-select:none;user-select:none;} .r-whiteSpace-3s2u2q{white-space:nowrap;} - .r-wordWrap-1iln25a{word-wrap:normal;}}" + .r-wordWrap-1iln25a{word-wrap:normal;}" `); // Second render "AlternativeComponent" @@ -148,7 +148,7 @@ describe('AppRegistry', () => { ); const second = getApplicationStyles('AlternativeApp'); expect(second).toMatchInlineSnapshot(` - "@layer rnw{[stylesheet-group="0"]{} + "[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} @@ -185,12 +185,31 @@ describe('AppRegistry', () => { .r-userSelect-1xnzce8{-moz-user-select:text;-webkit-user-select:text;user-select:text;} .r-userSelect-lrvibr{-moz-user-select:none;-webkit-user-select:none;user-select:none;} .r-whiteSpace-3s2u2q{white-space:nowrap;} - .r-wordWrap-1iln25a{word-wrap:normal;}}" + .r-wordWrap-1iln25a{word-wrap:normal;}" `); // Third render "RootComponent" again const third = getApplicationStyles('App'); expect(third).toEqual(second); }); + + test('"getStyleElement" renders the sheet in the cascade layer set for it', () => { + jest.isolateModules(() => { + const IsolatedAppRegistry = require('..'); + require('../../StyleSheet').setCascadeLayer('rnw'); + IsolatedAppRegistry.registerComponent('App', () => NoopComponent); + const { getStyleElement } = IsolatedAppRegistry.getApplication('App'); + const styleElement = ReactDOMServer.renderToStaticMarkup( + getStyleElement() + ); + + expect( + styleElement.startsWith( + '')).toBe(true); + }); + }); }); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js index 9521e960ef..c967ac7f04 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js @@ -5,211 +5,68 @@ * LICENSE file in the root directory of this source tree. */ -import { - CASCADE_LAYER_NAME, - isCascadeLayerRule, - resolveCascadeLayer, - wrapInCascadeLayer -} from '../dom/cascadeLayer'; -import createOrderedCSSStyleSheet from '../dom/createOrderedCSSStyleSheet'; +import { findCascadeLayer, wrapInCascadeLayer } from '../dom/cascadeLayer'; -/** - * A rule container with the two members this module consumes. The CSSOM in this test environment - * does not report a cascade layer's name, so the layered path is driven through these instead: a fake - * that reports the shape a CSSLayerBlockRule has, and one that reports the shape an engine without - * support produces. - */ -const createRuleContainer = ({ name } = {}) => { - const cssRules = []; - const container = { - cssRules, - insertRule(cssText, position) { - const selectorText = cssText.split('{')[0].trim(); - const nested = - selectorText === `@layer ${CASCADE_LAYER_NAME}` - ? // A supporting engine exposes the block as a nested container; one without support - // exposes it as an ordinary style rule, which has no 'name' and no 'insertRule'. - createRuleContainer({ - name: name === undefined ? CASCADE_LAYER_NAME : name - }) - : { cssText, selectorText }; - cssRules.splice(position, 0, nested); - return position; - }, - deleteRule(position) { - cssRules.splice(position, 1); - } - }; - if (name != null) { - container.name = name; - } - return container; -}; - -/** An engine that parses the layer text as an ordinary style rule — no 'name', no 'insertRule'. */ -const createLayerBlindContainer = () => { - const cssRules = []; - return { - cssRules, - insertRule(cssText, position) { - cssRules.splice(position, 0, { - cssText, - selectorText: cssText.split('{')[0].trim() - }); - return position; - }, - deleteRule(position) { - cssRules.splice(position, 1); - } - }; +const createStyleSheet = (cssText) => { + const element = document.createElement('style'); + element.appendChild(document.createTextNode(cssText)); + document.head.appendChild(element); + return element.sheet; }; -/** An engine that rejects the layer text outright. */ -const createRejectingContainer = () => ({ - cssRules: { length: 0 }, - insertRule() { - throw new Error('unsupported rule'); - }, - deleteRule() { - throw new Error('nothing to delete'); - } -}); - -describe('wrapInCascadeLayer', () => { - test('wraps css text in the layer block', () => { - expect(wrapInCascadeLayer('.a{opacity:1;}')).toMatchInlineSnapshot( - `"@layer rnw{.a{opacity:1;}}"` - ); +describe('findCascadeLayer', () => { + test('finds the layer block the page declared', () => { + const sheet = createStyleSheet('@layer rnw{}'); + const layer = findCascadeLayer(sheet); + expect(layer).not.toBe(null); + expect(layer.name).toBe('rnw'); + expect(layer.rule).toBe(sheet.cssRules[0]); }); - test('wraps empty text', () => { - expect(wrapInCascadeLayer('')).toMatchInlineSnapshot(`"@layer rnw{}"`); + test('reads the name of a nested layer', () => { + const layer = findCascadeLayer(createStyleSheet('@layer framework.rnw{}')); + expect(layer.name).toBe('framework.rnw'); }); - test('is deterministic', () => { - expect(wrapInCascadeLayer('.a{}')).toBe(wrapInCascadeLayer('.a{}')); + test('reads an anonymous layer as an empty name', () => { + const layer = findCascadeLayer(createStyleSheet('@layer{}')); + expect(layer.name).toBe(''); }); - test('is not idempotent, so a double wrap is visible rather than silent', () => { - // Each call nests one more layer block. The sheet's text is wrapped at exactly one seam, and a - // second wrap would change the output rather than being absorbed. - expect(wrapInCascadeLayer(wrapInCascadeLayer('.a{}'))).not.toBe( - wrapInCascadeLayer('.a{}') + test('skips rules that are not layer blocks', () => { + const sheet = createStyleSheet( + '.a{color:red;}@media print{.b{color:red;}}@layer rnw{}' ); + expect(findCascadeLayer(sheet).rule).toBe(sheet.cssRules[2]); }); -}); -describe('isCascadeLayerRule', () => { - test.each([ - ['null', null], - ['undefined', undefined], - ['a style rule', { cssText: '.a{}', selectorText: '.a' }], - ['a keyframes rule', { name: 'spin' }], - ['a differently named layer', { name: 'other', insertRule() {} }], - ['a layer block that reports no name', { insertRule() {} }], - ['a layer name without insertRule', { name: CASCADE_LAYER_NAME }] - ])('rejects %s', (_label, rule) => { - expect(isCascadeLayerRule(rule)).toBe(false); + test('ignores a layer statement, which groups no rules', () => { + // A CSSLayerStatementRule has no 'insertRule' + const sheet = { + cssRules: [{ cssText: '@layer rnw;' }], + insertRule() { + return 0; + }, + deleteRule() {} + }; + expect(findCascadeLayer(sheet)).toBe(null); }); - test('accepts a layer block rule', () => { - expect( - isCascadeLayerRule({ name: CASCADE_LAYER_NAME, insertRule() {} }) - ).toBe(true); + test('finds nothing in an unlayered or absent sheet', () => { + expect(findCascadeLayer(createStyleSheet('.a{color:red;}'))).toBe(null); + expect(findCascadeLayer(null)).toBe(null); }); }); -describe('resolveCascadeLayer', () => { - test('returns null for a missing sheet', () => { - expect(resolveCascadeLayer(null)).toBe(null); - expect(resolveCascadeLayer(undefined)).toBe(null); - }); - - test('creates the layer where the engine supports it', () => { - const sheet = createRuleContainer(); - const layer = resolveCascadeLayer(sheet); - expect(layer).not.toBe(null); - expect(isCascadeLayerRule(layer)).toBe(true); - expect(sheet.cssRules).toHaveLength(1); - }); - - test('is idempotent — a second call adopts the existing layer', () => { - const sheet = createRuleContainer(); - const first = resolveCascadeLayer(sheet); - const second = resolveCascadeLayer(sheet); - expect(second).toBe(first); - expect(sheet.cssRules).toHaveLength(1); - }); - - test('adopts a layer already present in a hydrated sheet', () => { - const sheet = createRuleContainer(); - sheet.insertRule(`@layer ${CASCADE_LAYER_NAME}{}`, 0); - const existing = sheet.cssRules[0]; - expect(resolveCascadeLayer(sheet)).toBe(existing); - expect(sheet.cssRules).toHaveLength(1); - }); - - test('leaves no rule behind when the engine cannot express a layer', () => { - // The regression this guards: a rule accepted but not exposed as a layer stays in the sheet, - // where the ordered sheet reads it as a rule belonging to a group that was never opened. - const sheet = createLayerBlindContainer(); - expect(resolveCascadeLayer(sheet)).toBe(null); - expect(sheet.cssRules).toHaveLength(0); - }); - - test('leaves the sheet untouched when the engine rejects the rule', () => { - const sheet = createRejectingContainer(); - expect(resolveCascadeLayer(sheet)).toBe(null); - expect(sheet.cssRules.length).toBe(0); - }); -}); - -describe('createOrderedCSSStyleSheet through a cascade layer', () => { - // The claim the layered design rests on: a layer block exposes the same two members the ordered - // sheet consumes, so grouping, ordering and de-duplication are unchanged one level deeper. - test('writes rules into the layer', () => { - const layer = resolveCascadeLayer(createRuleContainer()); - const sheet = createOrderedCSSStyleSheet(layer); - sheet.insert('.a{opacity:1;}', 0); - expect(layer.cssRules.map((rule) => rule.cssText)).toEqual([ - '[stylesheet-group="0"]{}', - '.a{opacity:1;}' - ]); - }); - - test('preserves group ordering inside the layer', () => { - const layer = resolveCascadeLayer(createRuleContainer()); - const sheet = createOrderedCSSStyleSheet(layer); - sheet.insert('.c{}', 2); - sheet.insert('.a{}', 0); - expect(sheet.getTextContent()).toMatchInlineSnapshot(` - "[stylesheet-group="0"]{} - .a{} - [stylesheet-group="2"]{} - .c{}" - `); - }); - - test('de-duplicates inside the layer', () => { - const layer = resolveCascadeLayer(createRuleContainer()); - const sheet = createOrderedCSSStyleSheet(layer); - sheet.insert('.a{}', 0); - sheet.insert('.a{}', 0); - expect(layer.cssRules).toHaveLength(2); - }); - - test('hydrates from rules already inside the layer', () => { - // The server-rendered path: the layer arrives populated, and the ordered sheet must read its - // groups back out rather than starting empty and re-inserting everything. - const layer = resolveCascadeLayer(createRuleContainer()); - layer.insertRule('[stylesheet-group="0"]{}', 0); - layer.insertRule('.a{opacity:1;}', 1); - const sheet = createOrderedCSSStyleSheet(layer); - sheet.insert('.a{opacity:1;}', 0); - expect(layer.cssRules).toHaveLength(2); - expect(sheet.getTextContent()).toMatchInlineSnapshot(` - "[stylesheet-group="0"]{} - .a{opacity:1;}" - `); +describe('wrapInCascadeLayer', () => { + test('wraps css text in a layer that is found under the same name', () => { + ['rnw', 'framework.rnw', ''].forEach((name) => { + const sheet = createStyleSheet( + wrapInCascadeLayer('.a{opacity:1;}', name) + ); + const layer = findCascadeLayer(sheet); + expect(layer.name).toBe(name); + expect(layer.rule.cssRules).toHaveLength(1); + }); }); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createCSSStyleSheet-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createCSSStyleSheet-test.js deleted file mode 100644 index 7cdbd11616..0000000000 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createCSSStyleSheet-test.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) Nicolas Gallagher. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import createCSSStyleSheet from '../dom/createCSSStyleSheet'; -import createOrderedCSSStyleSheet from '../dom/createOrderedCSSStyleSheet'; - -/** - * The CSSOM in this test environment does not report a cascade layer's name, so these tests pin the - * fallback: an engine without support must behave exactly as it did before layers were introduced. - * The layered path is covered by 'dom-cascadeLayer-test.js', which drives the ordered sheet through - * a rule container of the shape a CSSLayerBlockRule has. - */ -describe('createCSSStyleSheet', () => { - test('returns a container the ordered style sheet can hydrate', () => { - // An engine that parses the layer text as an ordinary style rule would leave that rule in the - // sheet, where hydration reads it as a rule belonging to a group that was never opened. - const container = createCSSStyleSheet('hydratable-sheet'); - expect(() => createOrderedCSSStyleSheet(container)).not.toThrow(); - }); - - test('leaves no rule behind when the engine cannot express a cascade layer', () => { - const container = createCSSStyleSheet('empty-sheet'); - expect(container.cssRules).toHaveLength(0); - }); - - test('inserted rules are readable back from the container', () => { - const sheet = createOrderedCSSStyleSheet( - createCSSStyleSheet('readable-sheet') - ); - sheet.insert('.a{opacity:1;}', 0); - expect(sheet.getTextContent()).toContain('.a{opacity:1;}'); - }); - - test('adopts the existing element when the id is already present', () => { - const first = createCSSStyleSheet('shared-sheet'); - const second = createCSSStyleSheet('shared-sheet'); - expect(second).toBe(first); - }); - - describe('with text content', () => { - // The text content path clones an existing sheet into a second root. Wrapping that text in a - // layer block an engine cannot parse invalidates the whole style element, which leaves no sheet - // at all rather than an unlayered one. - test('produces a usable sheet', () => { - const container = createCSSStyleSheet( - 'cloned-sheet', - document, - '[stylesheet-group="0"]{}\n.a{opacity:1;}' - ); - expect(container).not.toBe(null); - expect(container.cssRules.length).toBeGreaterThan(0); - }); - - test('the cloned rules hydrate into the ordered sheet', () => { - const sheet = createOrderedCSSStyleSheet( - createCSSStyleSheet( - 'cloned-hydrated-sheet', - document, - '[stylesheet-group="0"]{}\n.a{opacity:1;}' - ) - ); - expect(sheet.getTextContent()).toContain('.a'); - }); - }); -}); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createOrderedCSSStyleSheet-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createOrderedCSSStyleSheet-test.js index a57abb9e5b..f0b4a3c7a0 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createOrderedCSSStyleSheet-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-createOrderedCSSStyleSheet-test.js @@ -165,5 +165,21 @@ describe('createOrderedCSSStyleSheet', () => { .b {color: red;}" `); }); + + test('leaves rules the page declared before the first group to the page', () => { + element.appendChild( + document.createTextNode( + '.page { color: blue }\n[stylesheet-group="0"]{}\n.a { color: red }' + ) + ); + const clientSheet = createOrderedCSSStyleSheet(element.sheet); + clientSheet.insert('.page { color: red }', 0); + expect(clientSheet.getTextContent()).toMatchInlineSnapshot(` + "[stylesheet-group="0"] {} + .a {color: red;} + .page { color: red }" + `); + expect(element.sheet.cssRules[0].cssText).toBe('.page {color: blue;}'); + }); }); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.js index 79a7574803..693cad6d24 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.js @@ -5,7 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -import { CASCADE_LAYER_NAME } from '../dom/cascadeLayer'; import { createSheet } from '../dom'; describe('createSheet', () => { @@ -59,31 +58,255 @@ describe('createSheet', () => { expect(shadowSheet.getTextContent().includes('test-shadow')).toBe(true); }); - describe('#getTextContent', () => { - // Serialized text is delivered in the HTML document and read back by the next client that - // hydrates it. Emitted unlayered it would outrank the application's own layered rules until - // hydration replaced it, and hydration would find no layer to read the rules back out of — - // so every server-rendered rule would stay unlayered and be duplicated into an empty layer. - test('serializes into the cascade layer', () => { - const sheet = createSheet(); + // A fresh module per call, since the module keeps one sheet per document + const loadIsolatedModule = () => { + let isolatedModule; + jest.isolateModules(() => { + isolatedModule = require('../dom'); + }); + return isolatedModule; + }; + + const createDeclaredSheet = (id, declaredCssText) => { + const element = document.createElement('style'); + element.setAttribute('id', id); + element.appendChild(document.createTextNode(declaredCssText)); + document.head.appendChild(element); + const { createSheet, setCascadeLayer } = loadIsolatedModule(); + return { + createSheet, + element, + setCascadeLayer, + sheet: createSheet(undefined, id) + }; + }; + + const readRuleTexts = (container) => + Array.from(container.cssRules).map((rule) => rule.cssText); + + describe('in a cascade layer the page declares', () => { + test('inserts the rules into the layer', () => { + const { element, sheet } = createDeclaredSheet( + 'layer-insert', + '@layer rnw{}' + ); + sheet.insert('.test-layer { opacity: 1 }', 3); + + expect(element.sheet.cssRules).toHaveLength(1); + const layerRules = Array.from(element.sheet.cssRules[0].cssRules); + expect(layerRules.map((rule) => rule.cssText)).toEqual( + expect.arrayContaining([ + 'body {margin: 0;}', + '.test-layer {opacity: 1;}' + ]) + ); + }); + + test('serializes the rules in the layer', () => { + const { sheet } = createDeclaredSheet('layer-text', '@layer rnw{}'); + sheet.insert('.test-text { opacity: 1 }', 3); + const textContent = sheet.getTextContent(); - expect(textContent.startsWith(`@layer ${CASCADE_LAYER_NAME}{`)).toBe( - true + expect( + textContent.startsWith('@layer rnw{[stylesheet-group="0"]{}') + ).toBe(true); + expect(textContent.endsWith('.test-text { opacity: 1 }}')).toBe(true); + }); + + test('hydrates server-rendered rules from the layer', () => { + const { element, sheet } = createDeclaredSheet( + 'layer-hydrate', + '@layer rnw{[stylesheet-group="3"]{}\n.test-hydrated{opacity:1;}}' + ); + sheet.insert('.test-hydrated{opacity:1;}', 3); + + expect(element.sheet.cssRules).toHaveLength(1); + const hydrated = Array.from(element.sheet.cssRules[0].cssRules).filter( + (rule) => rule.cssText.startsWith('.test-hydrated') ); - expect(textContent.endsWith('}')).toBe(true); + expect(hydrated).toHaveLength(1); }); - test('nests the rules rather than placing them beside an empty layer', () => { - const sheet = createSheet(); - sheet.insert('.test-serialized { opacity: 1 }', 3); + test('nests the copy in another root in the same layer', () => { + const { createSheet, sheet } = createDeclaredSheet( + 'layer-copy', + '@layer rnw{}' + ); + sheet.insert('.test-copy-before { opacity: 1 }', 3); + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const iframeDoc = iframe.contentWindow.document; + const iframeRootTag = document.createElement('div'); + iframeDoc.body.appendChild(iframeRootTag); + const iframeSheet = createSheet(iframeRootTag, 'layer-copy'); + sheet.insert('.test-copy-after { opacity: 1 }', 3); + + const copy = iframeDoc.getElementById('layer-copy').sheet; + expect(copy.cssRules).toHaveLength(1); + const copiedRules = Array.from(copy.cssRules[0].cssRules); + expect(copiedRules.map((rule) => rule.cssText)).toEqual( + expect.arrayContaining([ + '.test-copy-before {opacity: 1;}', + '.test-copy-after {opacity: 1;}' + ]) + ); + expect(iframeSheet.getTextContent().startsWith('@layer rnw{')).toBe(true); + }); + + test('stays unlayered beside rules the page declares without a layer block', () => { + const { element, sheet } = createDeclaredSheet( + 'layer-none', + '.page { color: blue }' + ); + sheet.insert('.test-unlayered { opacity: 1 }', 3); + + const rules = Array.from(element.sheet.cssRules).map( + (rule) => rule.cssText + ); + expect(rules[0]).toBe('.page {color: blue;}'); + expect(rules).toContain('.test-unlayered {opacity: 1;}'); + expect( + sheet.getTextContent().startsWith('[stylesheet-group="0"]{}') + ).toBe(true); + }); + }); + + describe('setCascadeLayer', () => { + test('nests the rules inserted before and after the call', () => { + const { element, setCascadeLayer, sheet } = createDeclaredSheet( + 'set-layer', + '' + ); + sheet.insert('.test-before { opacity: 1 }', 3); + setCascadeLayer('rnw'); + sheet.insert('.test-after { opacity: 1 }', 3); + + expect(element.sheet.cssRules).toHaveLength(1); + expect(readRuleTexts(element.sheet.cssRules[0])).toEqual( + expect.arrayContaining([ + 'body {margin: 0;}', + '.test-before {opacity: 1;}', + '.test-after {opacity: 1;}' + ]) + ); const textContent = sheet.getTextContent(); - expect(textContent).toContain('.test-serialized'); - expect(textContent).not.toContain(`@layer ${CASCADE_LAYER_NAME}{}`); + expect(textContent.startsWith('@layer rnw{')).toBe(true); + expect(textContent.match(/\.test-before/g)).toHaveLength(1); + expect(textContent.match(/\.test-after/g)).toHaveLength(1); }); - test('is deterministic across calls', () => { - const sheet = createSheet(); - expect(sheet.getTextContent()).toBe(sheet.getTextContent()); + test('keeps the rules a page declared before them', () => { + const { element, setCascadeLayer } = createDeclaredSheet( + 'set-layer-page', + '.page { color: blue }' + ); + setCascadeLayer('rnw'); + + const rules = readRuleTexts(element.sheet); + expect(rules).toHaveLength(2); + expect(rules[0]).toBe('.page {color: blue;}'); + expect(rules[1].startsWith('@layer rnw {')).toBe(true); + }); + + test('leaves a sheet already in that layer as it is', () => { + const { element, setCascadeLayer } = createDeclaredSheet( + 'set-layer-same', + '@layer rnw{}' + ); + const layer = element.sheet.cssRules[0]; + const ruleCount = layer.cssRules.length; + setCascadeLayer('rnw'); + + expect(element.sheet.cssRules).toHaveLength(1); + expect(element.sheet.cssRules[0]).toBe(layer); + expect(layer.cssRules).toHaveLength(ruleCount); + }); + + test('moves the rules out of a layer of another name', () => { + const { element, setCascadeLayer, sheet } = createDeclaredSheet( + 'set-layer-rename', + '@layer page{}' + ); + sheet.insert('.test-renamed { opacity: 1 }', 3); + setCascadeLayer('rnw'); + + const rules = readRuleTexts(element.sheet); + expect(rules).toHaveLength(2); + expect(rules[0]).toBe('@layer page {}'); + expect(readRuleTexts(element.sheet.cssRules[1])).toContain( + '.test-renamed {opacity: 1;}' + ); }); + + test('nests a sheet created after the call', () => { + const element = document.createElement('style'); + element.setAttribute('id', 'set-layer-first'); + document.head.appendChild(element); + const { createSheet, setCascadeLayer } = loadIsolatedModule(); + setCascadeLayer('rnw'); + createSheet(undefined, 'set-layer-first'); + + expect(element.sheet.cssRules).toHaveLength(1); + expect(readRuleTexts(element.sheet.cssRules[0])).toContain( + 'body {margin: 0;}' + ); + }); + + test('nests the copies in other roots', () => { + const { createSheet, setCascadeLayer, sheet } = createDeclaredSheet( + 'set-layer-copy', + '' + ); + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const iframeDoc = iframe.contentWindow.document; + const iframeRootTag = document.createElement('div'); + iframeDoc.body.appendChild(iframeRootTag); + createSheet(iframeRootTag, 'set-layer-copy'); + setCascadeLayer('rnw'); + sheet.insert('.test-copy { opacity: 1 }', 3); + + const copy = iframeDoc.getElementById('set-layer-copy').sheet; + expect(copy.cssRules).toHaveLength(1); + expect(readRuleTexts(copy.cssRules[0])).toEqual( + expect.arrayContaining([ + 'body {margin: 0;}', + '.test-copy {opacity: 1;}' + ]) + ); + }); + + test('keeps the rules in place where the engine rejects a layer', () => { + expectRulesKeptInPlace('set-layer-rejected', () => { + throw new SyntaxError('Failed to parse the rule'); + }); + }); + + test('keeps the rules in place where the engine parses a layer as another rule', () => { + expectRulesKeptInPlace('set-layer-misparsed', (insertRule, position) => + insertRule('.misparsed-layer{}', position) + ); + }); + + const expectRulesKeptInPlace = (id, insertLayer) => { + const { element, setCascadeLayer, sheet } = createDeclaredSheet(id, ''); + const insertRule = element.sheet.insertRule.bind(element.sheet); + element.sheet.insertRule = (cssText, position) => + cssText.startsWith('@layer') + ? insertLayer(insertRule, position) + : insertRule(cssText, position); + const rules = readRuleTexts(element.sheet); + setCascadeLayer('rnw'); + sheet.insert('.test-unsupported { opacity: 1 }', 3); + + expect(readRuleTexts(element.sheet)).toEqual([ + ...rules, + '[stylesheet-group="3"] {}', + '.test-unsupported {opacity: 1;}' + ]); + expect( + sheet.getTextContent().startsWith('[stylesheet-group="0"]{}') + ).toBe(true); + }; }); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.node.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.node.js index 5e8de7a25a..f3c100f630 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.node.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-test.node.js @@ -13,22 +13,40 @@ describe('createSheet', () => { expect(typeof sheet.insert).toBe('function'); expect(sheet.id).toMatchInlineSnapshot(`"react-native-stylesheet"`); expect(sheet.getTextContent()).toMatchInlineSnapshot(` - "@layer rnw{[stylesheet-group="0"]{} + "[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} - input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}}" + input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}" `); sheet.insert('.test { opacity: 0 }', 1); expect(sheet.getTextContent()).toMatchInlineSnapshot(` - "@layer rnw{[stylesheet-group="0"]{} + "[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;} [stylesheet-group="1"]{} - .test { opacity: 0 }}" + .test { opacity: 0 }" `); }); + + test('serializes the sheet in the cascade layer set for it', () => { + jest.isolateModules(() => { + const dom = require('../dom'); + const sheet = dom.createSheet(); + sheet.insert('.test { opacity: 0 }', 1); + dom.setCascadeLayer('rnw'); + expect(sheet.getTextContent()).toMatchInlineSnapshot(` + "@layer rnw{[stylesheet-group="0"]{} + body{margin:0;} + button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} + html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} + input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;} + [stylesheet-group="1"]{} + .test { opacity: 0 }}" + `); + }); + }); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js index 90598c845c..888de2624e 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js @@ -13,7 +13,7 @@ describe('StyleSheet', () => { expect(StyleSheet.getSheet()).toMatchInlineSnapshot(` { "id": "react-native-stylesheet", - "textContent": "@layer rnw{[stylesheet-group="0"]{} + "textContent": "[stylesheet-group="0"]{} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} @@ -23,7 +23,7 @@ describe('StyleSheet', () => { .r-left-1d2f490{left:0px;} .r-position-u8s1d{position:absolute;} .r-right-zchlnj{right:0px;} - .r-top-ipm5af{top:0px;}}", + .r-top-ipm5af{top:0px;}", } `); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js b/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js index 590d8752f6..cc459855e1 100644 --- a/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js +++ b/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js @@ -9,89 +9,50 @@ import type { CSSRuleContainer } from './createOrderedCSSStyleSheet'; -/** - * The cascade layer the runtime style sheet's rules belong to. - * - * The rules this sheet holds are framework defaults, but an unlayered declaration takes precedence - * over every layered one regardless of specificity or layer order. Emitting them unlayered 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. - * - * Nesting them in a layer restores the intended precedence. Application styles stay unlayered and - * continue to win, and an application that uses layers can position this one by name. - */ -export const CASCADE_LAYER_NAME = 'rnw'; +export type CascadeLayer = {| + +name: string, + +rule: CSSRuleContainer +|}; -const layerBlockPrefix = `@layer ${CASCADE_LAYER_NAME}{`; - -/** - * Serialize css text as the contents of the cascade layer. Pure, total, and deterministic: the - * single place the layer's syntax is written, so the runtime sheet and the extracted text cannot - * disagree about what the layer is called or how it is opened. - */ -export function wrapInCascadeLayer(cssText: string): string { - return `${layerBlockPrefix}${cssText}}`; -} +const layerBlockPattern = /^@layer\s*([^{]*?)\s*\{/; /** - * A CSSLayerBlockRule is the only rule type exposing both 'name' and 'insertRule'. - * CSSKeyframesRule has a 'name' and no 'insertRule', and the legacy numeric 'type' reports 0 for - * every rule added after that enum was frozen, so it distinguishes nothing. + * The cascade layer a rule opens, when the rule is a layer block. A layer statement such as + * `@layer rnw;` groups no rules, so it has no 'insertRule'. */ -export function isCascadeLayerRule(rule: ?Object): boolean { - return ( - rule != null && - rule.name === CASCADE_LAYER_NAME && - typeof rule.insertRule === 'function' - ); +export function readCascadeLayer(rule: Object): ?CascadeLayer { + if (typeof rule.insertRule !== 'function') { + return null; + } + const match = layerBlockPattern.exec(rule.cssText); + return match != null ? { name: match[1], rule } : null; } /** - * The sheet's own cascade layer block, or null where the CSSOM cannot express one. - * - * Support is measured 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, and - * a style element whose text is wrapped in a layer block may fail to parse in full. A rule that - * turns out not to be a layer is removed again, so an unsupported engine is left exactly as it was. + * The first cascade layer block in a style sheet, e.g. one a page declared in the style sheet's + * element, ``, or one a server rendered. + * The runtime rules are inserted into it, so a page's unlayered and later-layered styles take + * precedence over them. An engine without cascade layers drops the block, which leaves the sheet + * unlayered. */ -export function resolveCascadeLayer( - sheet: ?CSSRuleContainer -): ?CSSRuleContainer { +export function findCascadeLayer(sheet: ?CSSRuleContainer): ?CascadeLayer { if (sheet == null) { return null; } const rules = sheet.cssRules; for (let i = 0; i < rules.length; i += 1) { - if (isCascadeLayerRule(rules[i])) { - return rules[i]; + const layer = readCascadeLayer(rules[i]); + if (layer != null) { + return layer; } } - const position = rules.length; - try { - sheet.insertRule(`${layerBlockPrefix}}`, position); - } catch (e) { - return null; - } - if (isCascadeLayerRule(rules[position])) { - return rules[position]; - } - sheet.deleteRule(position); return null; } /** - * Whether this document's CSSOM implements cascade layers. Measured against a detached probe so - * the answer is available before a style element's text is composed, and leaves the document as - * it was found. + * Serialize css text as the contents of the layer block named 'name', an anonymous layer when the + * name is empty, so a copy of the sheet carries the same precedence as the sheet it was taken from. */ -export function documentSupportsCascadeLayer(): boolean { - const head = document.head; - if (head == null) { - return false; - } - const probe = document.createElement('style'); - head.appendChild(probe); - const supported = resolveCascadeLayer(probe.sheet) != null; - head.removeChild(probe); - return supported; +export function wrapInCascadeLayer(cssText: string, name: string): string { + return name === '' ? `@layer{${cssText}}` : `@layer ${name}{${cssText}}`; } diff --git a/packages/react-native-web/src/exports/StyleSheet/dom/createCSSStyleSheet.js b/packages/react-native-web/src/exports/StyleSheet/dom/createCSSStyleSheet.js index 6e0e557ab5..93b961a14d 100644 --- a/packages/react-native-web/src/exports/StyleSheet/dom/createCSSStyleSheet.js +++ b/packages/react-native-web/src/exports/StyleSheet/dom/createCSSStyleSheet.js @@ -6,13 +6,6 @@ * * @flow strict-local */ - -import type { CSSRuleContainer } from './createOrderedCSSStyleSheet'; -import { - documentSupportsCascadeLayer, - resolveCascadeLayer, - wrapInCascadeLayer -} from './cascadeLayer'; import canUseDOM from '../../../modules/canUseDom'; // $FlowFixMe: HTMLStyleElement is incorrectly typed - https://github.com/facebook/flow/issues/2696 @@ -20,7 +13,7 @@ export default function createCSSStyleSheet( id: string, rootNode?: Document | ShadowRoot, textContent?: string -): ?CSSRuleContainer { +): ?CSSStyleSheet { if (canUseDOM) { const root = rootNode != null ? rootNode : document; let element = root.getElementById(id); @@ -28,13 +21,7 @@ export default function createCSSStyleSheet( element = document.createElement('style'); element.setAttribute('id', id); if (typeof textContent === 'string') { - element.appendChild( - document.createTextNode( - documentSupportsCascadeLayer() - ? wrapInCascadeLayer(textContent) - : textContent - ) - ); + element.appendChild(document.createTextNode(textContent)); } if (root instanceof ShadowRoot) { root.insertBefore(element, root.firstChild); @@ -46,9 +33,7 @@ export default function createCSSStyleSheet( } } // $FlowFixMe: HTMLElement is incorrectly typed - const sheet: ?CSSRuleContainer = element.sheet; - const layer = resolveCascadeLayer(sheet); - return layer != null ? layer : sheet; + return element.sheet; } else { return null; } diff --git a/packages/react-native-web/src/exports/StyleSheet/dom/createOrderedCSSStyleSheet.js b/packages/react-native-web/src/exports/StyleSheet/dom/createOrderedCSSStyleSheet.js index 2f5ba38c20..213bca0c00 100644 --- a/packages/react-native-web/src/exports/StyleSheet/dom/createOrderedCSSStyleSheet.js +++ b/packages/react-native-web/src/exports/StyleSheet/dom/createOrderedCSSStyleSheet.js @@ -60,7 +60,8 @@ export default function createOrderedCSSStyleSheet( if (cssText.indexOf('stylesheet-group') > -1) { group = decodeGroupRule(cssRule); groups[group] = { start: i, rules: [cssText] }; - } else { + } else if (group != null) { + // Rules before the first group are the page's own, not this sheet's const selectorText = getSelectorText(cssText); if (selectorText != null) { selectors[selectorText] = true; diff --git a/packages/react-native-web/src/exports/StyleSheet/dom/index.js b/packages/react-native-web/src/exports/StyleSheet/dom/index.js index fb3056d802..baded93419 100644 --- a/packages/react-native-web/src/exports/StyleSheet/dom/index.js +++ b/packages/react-native-web/src/exports/StyleSheet/dom/index.js @@ -7,8 +7,15 @@ * @flow strict-local */ -import type { OrderedCSSStyleSheet } from './createOrderedCSSStyleSheet'; -import { wrapInCascadeLayer } from './cascadeLayer'; +import type { + CSSRuleContainer, + OrderedCSSStyleSheet +} from './createOrderedCSSStyleSheet'; +import { + findCascadeLayer, + readCascadeLayer, + wrapInCascadeLayer +} from './cascadeLayer'; import canUseDOM from '../../../modules/canUseDom'; import createCSSStyleSheet from './createCSSStyleSheet'; import createOrderedCSSStyleSheet from './createOrderedCSSStyleSheet'; @@ -18,9 +25,19 @@ type Sheet = { id: string }; +type RootSheet = {| + // The root's style sheet, which is null where there is no DOM + +cssSheet: ?CSSRuleContainer, + // The style sheet, or the cascade layer block in it, that holds the rules + +container: ?CSSRuleContainer, + +layerName: ?string, + +sheet: OrderedCSSStyleSheet +|}; + const defaultId = 'react-native-stylesheet'; const roots = new WeakMap(); -const sheets = []; +const sheets: Array = []; +let cascadeLayerName: ?string = null; const initialRules = [ // minimal top-level reset @@ -31,66 +48,146 @@ const initialRules = [ 'input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}' ]; +function indexOfFirstGroup(container: CSSRuleContainer): number { + for (let i = 0; i < container.cssRules.length; i += 1) { + if (container.cssRules[i].cssText.indexOf('stylesheet-group') > -1) { + return i; + } + } + return -1; +} + +/** + * Move a root's rules into the cascade layer 'layerName'. The layer block is inserted before the + * rules are removed, so an engine that cannot parse it keeps them where they were. + */ +function nestRootSheet(rootSheet: RootSheet, layerName: string): RootSheet { + const { cssSheet, container } = rootSheet; + if (cssSheet == null || container == null) { + return { + cssSheet, + container, + layerName, + sheet: rootSheet.sheet + }; + } + const position = cssSheet.cssRules.length; + try { + cssSheet.insertRule( + wrapInCascadeLayer(rootSheet.sheet.getTextContent(), layerName), + position + ); + } catch (e) { + return rootSheet; + } + const layer = readCascadeLayer(cssSheet.cssRules[position]); + if (layer == null) { + cssSheet.deleteRule(position); + return rootSheet; + } + // The rules run from the container's first group to its end, or to the block just inserted + const start = indexOfFirstGroup(container); + if (start > -1) { + const end = container === cssSheet ? position : container.cssRules.length; + for (let i = end - 1; i >= start; i -= 1) { + container.deleteRule(i); + } + } + return { + cssSheet, + container: layer.rule, + layerName, + sheet: createOrderedCSSStyleSheet(layer.rule) + }; +} + +function createRootSheet(cssSheet: ?CSSRuleContainer): RootSheet { + const layer = findCascadeLayer(cssSheet); + const container = layer != null ? layer.rule : cssSheet; + const rootSheet = { + cssSheet, + container, + layerName: layer != null ? layer.name : null, + sheet: createOrderedCSSStyleSheet(container) + }; + return cascadeLayerName != null && rootSheet.layerName !== cascadeLayerName + ? nestRootSheet(rootSheet, cascadeLayerName) + : rootSheet; +} + +function getRootTextContent(rootSheet: RootSheet): string { + const textContent = rootSheet.sheet.getTextContent(); + return rootSheet.layerName != null + ? wrapInCascadeLayer(textContent, rootSheet.layerName) + : textContent; +} + +/** + * Nest the rules of every root, and of every root created later, in the cascade layer 'name', so + * a page that orders its own styles in layers can position them among its own. + */ +export function setCascadeLayer(name: string): void { + cascadeLayerName = name; + sheets.forEach((rootSheet, index) => { + if (rootSheet.layerName !== name) { + sheets[index] = nestRootSheet(rootSheet, name); + } + }); +} + export function createSheet( root?: HTMLElement, id?: string = defaultId ): Sheet { - let sheet; + let index: number = 0; if (canUseDOM) { const rootNode: Node = root != null ? root.getRootNode() : document; + const rootIndex = roots.get(rootNode); // Create the initial style sheet if (sheets.length === 0) { - sheet = createOrderedCSSStyleSheet(createCSSStyleSheet(id)); + const rootSheet = createRootSheet(createCSSStyleSheet(id)); initialRules.forEach((rule) => { - sheet.insert(rule, 0); + rootSheet.sheet.insert(rule, 0); }); - roots.set(rootNode, sheets.length); - sheets.push(sheet); + roots.set(rootNode, index); + sheets.push(rootSheet); + } else if (rootIndex == null) { + const initialSheet = sheets[0]; + // If we're creating a new sheet, populate it with existing styles, nested + // in the same cascade layer + const textContent = + initialSheet != null ? getRootTextContent(initialSheet) : ''; + // Cast rootNode to 'any' because Flow types for getRootNode are wrong + const rootSheet = createRootSheet( + createCSSStyleSheet(id, (rootNode: any), textContent) + ); + index = sheets.length; + roots.set(rootNode, index); + sheets.push(rootSheet); } else { - const index = roots.get(rootNode); - if (index == null) { - const initialSheet = sheets[0]; - // If we're creating a new sheet, populate it with existing styles - const textContent = - initialSheet != null ? initialSheet.getTextContent() : ''; - // Cast rootNode to 'any' because Flow types for getRootNode are wrong - sheet = createOrderedCSSStyleSheet( - createCSSStyleSheet(id, (rootNode: any), textContent) - ); - roots.set(rootNode, sheets.length); - sheets.push(sheet); - } else { - sheet = sheets[index]; - } + index = rootIndex; } } else { // Create the initial style sheet if (sheets.length === 0) { - sheet = createOrderedCSSStyleSheet(createCSSStyleSheet(id)); + const rootSheet = createRootSheet(createCSSStyleSheet(id)); initialRules.forEach((rule) => { - sheet.insert(rule, 0); + rootSheet.sheet.insert(rule, 0); }); - sheets.push(sheet); - } else { - sheet = sheets[0]; + sheets.push(rootSheet); } } + const sheetIndex = index; return { - /** - * The style sheet serialized for delivery in an HTML document. Wrapped in the cascade layer so - * server-rendered rules carry the same precedence the runtime sheet gives them: emitted - * unlayered, they would outrank the application's own layered rules until hydration replaced - * them, and hydration would find no layer to read the rules back out of. - */ getTextContent() { - return wrapInCascadeLayer(sheet.getTextContent()); + return getRootTextContent(sheets[sheetIndex]); }, id, insert(cssText: string, groupValue: number) { sheets.forEach((s) => { - s.insert(cssText, groupValue); + s.sheet.insert(cssText, groupValue); }); } }; diff --git a/packages/react-native-web/src/exports/StyleSheet/index.js b/packages/react-native-web/src/exports/StyleSheet/index.js index 05d2826c16..c30388c64f 100644 --- a/packages/react-native-web/src/exports/StyleSheet/index.js +++ b/packages/react-native-web/src/exports/StyleSheet/index.js @@ -8,8 +8,7 @@ */ import { atomic, classic, inline } from './compiler'; -import { CASCADE_LAYER_NAME } from './dom/cascadeLayer'; -import { createSheet } from './dom'; +import { createSheet, setCascadeLayer } from './dom'; import { localizeStyle } from 'styleq/transform-localize-style'; import { preprocess } from './preprocess'; import { styleq } from 'styleq'; @@ -170,10 +169,6 @@ function StyleSheet(styles: any, options?: Options = {}): StyleProps { StyleSheet.absoluteFill = absoluteFill; StyleSheet.absoluteFillObject = absoluteFillObject; -// The cascade layer the runtime style sheet's rules belong to. Exported so an application can -// position it against its own layers with an `@layer` statement, rather than depending on the -// order style elements happen to be inserted in. -StyleSheet.cascadeLayerName = CASCADE_LAYER_NAME; StyleSheet.create = create; StyleSheet.compose = compose; StyleSheet.flatten = flatten; @@ -181,6 +176,7 @@ StyleSheet.getSheet = getSheet; // `hairlineWidth` is not implemented using screen density as browsers may // round sub-pixel values down to `0`, causing the line not to be rendered. StyleSheet.hairlineWidth = 1; +StyleSheet.setCascadeLayer = setCascadeLayer; if (canUseDOM && window.__REACT_DEVTOOLS_GLOBAL_HOOK__) { window.__REACT_DEVTOOLS_GLOBAL_HOOK__.resolveRNStyle = StyleSheet.flatten; @@ -190,12 +186,12 @@ export type IStyleSheet = { (styles: $ReadOnlyArray, options?: Options): StyleProps, absoluteFill: Object, absoluteFillObject: Object, - cascadeLayerName: string, create: typeof create, compose: typeof compose, flatten: typeof flatten, getSheet: typeof getSheet, - hairlineWidth: number + hairlineWidth: number, + setCascadeLayer: typeof setCascadeLayer }; const stylesheet: IStyleSheet = StyleSheet;