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 082bdd1fe5..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 @@ -192,5 +192,24 @@ describe('AppRegistry', () => { 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 new file mode 100644 index 0000000000..c967ac7f04 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/dom-cascadeLayer-test.js @@ -0,0 +1,72 @@ +/** + * 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 { findCascadeLayer, wrapInCascadeLayer } from '../dom/cascadeLayer'; + +const createStyleSheet = (cssText) => { + const element = document.createElement('style'); + element.appendChild(document.createTextNode(cssText)); + document.head.appendChild(element); + return element.sheet; +}; + +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('reads the name of a nested layer', () => { + const layer = findCascadeLayer(createStyleSheet('@layer framework.rnw{}')); + expect(layer.name).toBe('framework.rnw'); + }); + + test('reads an anonymous layer as an empty name', () => { + const layer = findCascadeLayer(createStyleSheet('@layer{}')); + expect(layer.name).toBe(''); + }); + + 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]); + }); + + 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('finds nothing in an unlayered or absent sheet', () => { + expect(findCascadeLayer(createStyleSheet('.a{color:red;}'))).toBe(null); + expect(findCascadeLayer(null)).toBe(null); + }); +}); + +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-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 533b2b8c7c..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 @@ -57,4 +57,256 @@ describe('createSheet', () => { sheet.insert('.test-shadow { opacity: 0 }', 3); expect(shadowSheet.getTextContent().includes('test-shadow')).toBe(true); }); + + // 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 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(hydrated).toHaveLength(1); + }); + + 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.startsWith('@layer rnw{')).toBe(true); + expect(textContent.match(/\.test-before/g)).toHaveLength(1); + expect(textContent.match(/\.test-after/g)).toHaveLength(1); + }); + + 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 2ad1015dd3..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 @@ -31,4 +31,22 @@ describe('createSheet', () => { .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/dom/cascadeLayer.js b/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js new file mode 100644 index 0000000000..cc459855e1 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/dom/cascadeLayer.js @@ -0,0 +1,58 @@ +/** + * 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'; + +export type CascadeLayer = {| + +name: string, + +rule: CSSRuleContainer +|}; + +const layerBlockPattern = /^@layer\s*([^{]*?)\s*\{/; + +/** + * 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 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 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 findCascadeLayer(sheet: ?CSSRuleContainer): ?CascadeLayer { + if (sheet == null) { + return null; + } + const rules = sheet.cssRules; + for (let i = 0; i < rules.length; i += 1) { + const layer = readCascadeLayer(rules[i]); + if (layer != null) { + return layer; + } + } + return null; +} + +/** + * 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 wrapInCascadeLayer(cssText: string, name: string): string { + return name === '' ? `@layer{${cssText}}` : `@layer ${name}{${cssText}}`; +} 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..213bca0c00 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 = {}; @@ -49,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 c55fd5a382..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,7 +7,15 @@ * @flow strict-local */ -import type { OrderedCSSStyleSheet } from './createOrderedCSSStyleSheet'; +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'; @@ -17,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 @@ -30,60 +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 { getTextContent() { - return 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 a13393665b..c30388c64f 100644 --- a/packages/react-native-web/src/exports/StyleSheet/index.js +++ b/packages/react-native-web/src/exports/StyleSheet/index.js @@ -8,7 +8,7 @@ */ import { atomic, classic, inline } from './compiler'; -import { createSheet } from './dom'; +import { createSheet, setCascadeLayer } from './dom'; import { localizeStyle } from 'styleq/transform-localize-style'; import { preprocess } from './preprocess'; import { styleq } from 'styleq'; @@ -176,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; @@ -189,7 +190,8 @@ export type IStyleSheet = { compose: typeof compose, flatten: typeof flatten, getSheet: typeof getSheet, - hairlineWidth: number + hairlineWidth: number, + setCascadeLayer: typeof setCascadeLayer }; const stylesheet: IStyleSheet = StyleSheet;