From 29be779f7732d97136b22859386f27b70748ddb8 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Mon, 21 Jan 2019 12:50:48 -0800 Subject: [PATCH] [change] CSS insertion by OrderedCSSStyleSheet `OrderedCSSStyleSheet` can be used to control the order in which CSS rules are inserted. This feature is necessary to support the combined use of Classic CSS and Atomic CSS. It also makes it possible to control the order of Atomic CSS rules, which is necessary to correctly resolve style conflicts (e.g., between 'margin' and 'marginHorizontal') without expanding short-form properties to long-form properties. Ref #1136 --- .eslintrc | 1 + .../__snapshots__/index-test.js.snap | 4 +- .../exports/StyleSheet/StyleSheetManager.js | 31 +++- .../src/exports/StyleSheet/WebStyleSheet.js | 70 -------- .../createOrderedCSSStyleSheet-test.js.snap | 83 ++++++++++ .../createOrderedCSSStyleSheet-test.js | 87 ++++++++++ .../StyleSheet/createOrderedCSSStyleSheet.js | 154 ++++++++++++++++++ .../src/exports/StyleSheet/initialRules.js | 7 +- .../src/exports/StyleSheet/modality.js | 6 +- 9 files changed, 357 insertions(+), 86 deletions(-) delete mode 100644 packages/react-native-web/src/exports/StyleSheet/WebStyleSheet.js create mode 100644 packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/createOrderedCSSStyleSheet-test.js.snap create mode 100644 packages/react-native-web/src/exports/StyleSheet/__tests__/createOrderedCSSStyleSheet-test.js create mode 100644 packages/react-native-web/src/exports/StyleSheet/createOrderedCSSStyleSheet.js diff --git a/.eslintrc b/.eslintrc index a01717a0..7de99efb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -34,6 +34,7 @@ "window": false, // Flow global types, "$Enum": false, + "CSSStyleSheet": false, "HTMLInputElement": false, "ReactClass": false, "ReactComponent": false, diff --git a/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/index-test.js.snap index 7e43cd41..d5b6eb6f 100644 --- a/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/index-test.js.snap @@ -9,7 +9,9 @@ exports[`AppRegistry getApplication returns "element" and "getStyleElement" 1`] `; exports[`AppRegistry getApplication returns "element" and "getStyleElement" 2`] = ` -"`; - if (document.head) { - document.head.insertAdjacentHTML('afterbegin', html); - domStyleElement = document.getElementById(id); - } - } - - if (domStyleElement) { - modality(domStyleElement); - // $FlowFixMe - this._sheet = domStyleElement.sheet; - this._textContent = domStyleElement.textContent; - } - } - } - - containsRule(rule: string): boolean { - return this._cssRules.indexOf(rule) > -1; - } - - get cssText(): string { - return this._cssRules.join('\n'); - } - - insertRuleOnce(rule: string, position: ?number) { - // Reduce chance of duplicate rules - if (!this.containsRule(rule)) { - this._cssRules.push(rule); - - // Check whether a rule was part of any prerendered styles (textContent - // doesn't include styles injected via 'insertRule') - if (this._textContent.indexOf(rule) === -1 && this._sheet) { - const pos = position || this._sheet.cssRules.length; - try { - this._sheet.insertRule(rule, pos); - } catch (e) { - if (process.env.NODE_ENV !== 'production') { - console.warn( - `Failed to inject CSS: "${rule}". The browser may have rejecting unrecognized vendor prefixes. (This should have no user-facing impact.)` - ); - } - } - } - } - } -} diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/createOrderedCSSStyleSheet-test.js.snap b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/createOrderedCSSStyleSheet-test.js.snap new file mode 100644 index 00000000..15ab3746 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/createOrderedCSSStyleSheet-test.js.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createOrderedCSSStyleSheet #insert deduplication for same group 1`] = `""`; + +exports[`createOrderedCSSStyleSheet #insert deduplication for same group 2`] = ` +"@media all { +[stylesheet-group=\\"0\\"]{} +.one {} +}" +`; + +exports[`createOrderedCSSStyleSheet #insert deduplication for same group 3`] = ` +"@media all { +[stylesheet-group=\\"0\\"]{} +.one {} +}" +`; + +exports[`createOrderedCSSStyleSheet #insert insertion order for different groups 1`] = ` +"@media all { +[stylesheet-group=\\"1\\"]{} +.one {} +} +@media all { +[stylesheet-group=\\"2.2\\"]{} +.two {} +} +@media all { +[stylesheet-group=\\"3\\"]{} +.three {} +} +@media all { +[stylesheet-group=\\"4\\"]{} +.four-1 {} +.four-2 {} +} +@media all { +[stylesheet-group=\\"9.9\\"]{} +.nine-1 {} +.nine-2 {} +}" +`; + +exports[`createOrderedCSSStyleSheet #insert insertion order for same group 1`] = `""`; + +exports[`createOrderedCSSStyleSheet #insert insertion order for same group 2`] = ` +"@media all { +[stylesheet-group=\\"0\\"]{} +.one {} +}" +`; + +exports[`createOrderedCSSStyleSheet #insert insertion order for same group 3`] = ` +"@media all { +[stylesheet-group=\\"0\\"]{} +.one {} +.two {} +}" +`; + +exports[`createOrderedCSSStyleSheet #insert insertion order for same group 4`] = ` +"@media all { +[stylesheet-group=\\"0\\"]{} +.one {} +.two {} +.three {} +}" +`; + +exports[`createOrderedCSSStyleSheet client-side hydration from SSR CSS 1`] = ` +"@media all { +[stylesheet-group=\\"1\\"] {} +.one {width: 10px;} +} +@media all { +[stylesheet-group=\\"2\\"] {} +.two-1 {height: 20px;} +.two-2 {color: red;} +@keyframes anim { + 0% {opacity: 1;} +} +}" +`; diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/createOrderedCSSStyleSheet-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/createOrderedCSSStyleSheet-test.js new file mode 100644 index 00000000..ba986710 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/createOrderedCSSStyleSheet-test.js @@ -0,0 +1,87 @@ +/* eslint-env jasmine, jest */ + +'use strict'; + +import createOrderedCSSStyleSheet from '../createOrderedCSSStyleSheet'; + +const insertStyleElement = () => { + const element = document.createElement('style'); + const head = document.head; + head.insertBefore(element, head.firstChild); + return element; +}; + +const removeStyleElement = element => { + document.head.removeChild(element); +}; + +describe('createOrderedCSSStyleSheet', () => { + describe('#insert', () => { + test('insertion order for same group', () => { + const sheet = createOrderedCSSStyleSheet(); + + expect(sheet.getTextContent()).toMatchSnapshot(); + + sheet.insert('.one {}', 0); + expect(sheet.getTextContent()).toMatchSnapshot(); + + sheet.insert('.two {}', 0); + expect(sheet.getTextContent()).toMatchSnapshot(); + + sheet.insert('.three {}', 0); + expect(sheet.getTextContent()).toMatchSnapshot(); + }); + + test('deduplication for same group', () => { + const sheet = createOrderedCSSStyleSheet(); + + expect(sheet.getTextContent()).toMatchSnapshot(); + + sheet.insert('.one {}', 0); + expect(sheet.getTextContent()).toMatchSnapshot(); + + sheet.insert('.one {}', 0); + expect(sheet.getTextContent()).toMatchSnapshot(); + }); + + test('insertion order for different groups', () => { + const sheet = createOrderedCSSStyleSheet(); + + sheet.insert('.nine-1 {}', 9.9); + sheet.insert('.nine-2 {}', 9.9); + sheet.insert('.three {}', 3); + sheet.insert('.one {}', 1); + sheet.insert('.two {}', 2.2); + sheet.insert('.four-1 {}', 4); + sheet.insert('.four-2 {}', 4); + + expect(sheet.getTextContent()).toMatchSnapshot(); + }); + }); + + describe('client-side hydration', () => { + let element; + + beforeEach(() => { + if (element != null) { + removeStyleElement(element); + } + element = insertStyleElement(); + }); + + test('from SSR CSS', () => { + // Setup SSR CSS + const serverSheet = createOrderedCSSStyleSheet(); + serverSheet.insert('.one { width: 10px; }', 1); + serverSheet.insert('.two-1 { height: 20px; }', 2); + serverSheet.insert('.two-2 { color: red; }', 2); + serverSheet.insert('@keyframes anim { 0% { opacity: 1; } }', 2); + const textContent = serverSheet.getTextContent(); + + // Add SSR CSS to client style sheet + element.appendChild(document.createTextNode(textContent)); + const clientSheet = createOrderedCSSStyleSheet(element.sheet); + expect(clientSheet.getTextContent()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/react-native-web/src/exports/StyleSheet/createOrderedCSSStyleSheet.js b/packages/react-native-web/src/exports/StyleSheet/createOrderedCSSStyleSheet.js new file mode 100644 index 00000000..b60c8553 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/createOrderedCSSStyleSheet.js @@ -0,0 +1,154 @@ +/** + * 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 + */ + +type Groups = { [key: number]: Array }; +type Selectors = { [key: string]: boolean }; + +const slice = Array.prototype.slice; + +/** + * Order-based insertion of CSS. + * + * Each rule can be inserted (appended) into a numerically defined group. + * Groups are ordered within the style sheet according to their number, with the + * lowest first. + * + * Groups are implemented using Media Query blocks. CSSMediaRule implements the + * CSSGroupingRule, which includes 'insertRule', allowing groups to be treated as + * a sub-sheet. + * https://developer.mozilla.org/en-US/docs/Web/API/CSSMediaRule + * + * The selector of the first rule of each group is used only to encode the group + * number for hydration. + */ +export default function createOrderedCSSStyleSheet(sheet: ?CSSStyleSheet) { + const groups: Groups = {}; + const selectors: Selectors = {}; + + /** + * Hydrate approximate record from any existing rules in the sheet. + */ + if (sheet != null) { + slice.call(sheet.cssRules).forEach(mediaRule => { + if (mediaRule.media == null) { + throw new Error( + 'OrderedCSSStyleSheet: hydrating invalid stylesheet. Expected only @media rules.' + ); + } + + // Create group number + const group = decodeGroupRule(mediaRule); + groups[group] = []; + + // Create record of existing selectors and rules + slice.call(mediaRule.cssRules).forEach(rule => { + const selectorText = getSelectorText(rule.cssText); + if (selectorText != null) { + selectors[selectorText] = true; + groups[group].push(rule.cssText); + } + }); + }); + } + + const OrderedCSSStyleSheet = { + /** + * The textContent of the style sheet. + * Each group's rules are wrapped in a media query. + */ + getTextContent(): string { + return getOrderedGroups(groups) + .map(group => { + const rules = groups[group]; + const str = rules.join('\n'); + return createMediaRule(str); + }) + .join('\n'); + }, + + /** + * Insert a rule into a media query in the style sheet + */ + insert(cssText: string, group: number) { + // Create a new group. + if (groups[group] == null) { + const markerRule = encodeGroupRule(group); + + // Create the internal record. + groups[group] = []; + groups[group].push(markerRule); + + // Create CSSOM CSSMediaRule. + if (sheet != null) { + const groupIndex = getOrderedGroups(groups).indexOf(group); + insertRuleAt(sheet, createMediaRule(markerRule), groupIndex); + } + } + + // selectorText is more reliable than cssText for insertion checks. The + // browser excludes vendor-prefixed properties and rewrites certain values + // making cssText more likely to be different from what was inserted. + const selectorText = getSelectorText(cssText); + if (selectorText != null && selectors[selectorText] == null) { + // Update the internal records. + selectors[selectorText] = true; + groups[group].push(cssText); + // Update CSSOM CSSMediaRule. + if (sheet != null) { + const groupIndex = getOrderedGroups(groups).indexOf(group); + const root = sheet.cssRules[groupIndex]; + if (root != null) { + // $FlowFixMe: Flow is missing CSSOM types + insertRuleAt(root, cssText, root.cssRules.length); + } + } + } + } + }; + + return OrderedCSSStyleSheet; +} + +/** + * Helper functions + */ + +function createMediaRule(content) { + return `@media all {\n${content}\n}`; +} + +function encodeGroupRule(group) { + return `[stylesheet-group="${group}"]{}`; +} + +function decodeGroupRule(mediaRule) { + return mediaRule.cssRules[0].selectorText.split('"')[1]; +} + +function getOrderedGroups(obj: { [key: number]: any }) { + return Object.keys(obj) + .sort() + .map(k => Number(k)); +} + +const pattern = /\s*([,])\s*/g; +function getSelectorText(cssText) { + const selector = cssText.split('{')[0].trim(); + return selector !== '' ? selector.replace(pattern, '$1') : null; +} + +function insertRuleAt(root, cssText: string, position: number) { + try { + // $FlowFixMe: Flow is missing CSSOM types needed to type 'root'. + root.insertRule(cssText, position); + } catch (e) { + // JSDOM doesn't support `CSSSMediaRule#insertRule`. + // Also ignore errors that occur from attempting to insert vendor-prefixed selectors. + } +} diff --git a/packages/react-native-web/src/exports/StyleSheet/initialRules.js b/packages/react-native-web/src/exports/StyleSheet/initialRules.js index ebb289af..f6cd4a69 100644 --- a/packages/react-native-web/src/exports/StyleSheet/initialRules.js +++ b/packages/react-native-web/src/exports/StyleSheet/initialRules.js @@ -7,9 +7,6 @@ * @flow */ -// Prevent browsers throwing parse errors, e.g., on vendor-prefixed pseudo-elements -const safeRule = rule => `@media all{\n${rule}\n}`; - const resets = [ // minimal top-level reset 'html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);}', @@ -21,6 +18,4 @@ const resets = [ 'input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}' ]; -const reset = [safeRule(resets.join('\n'))]; - -export default reset; +export default resets; diff --git a/packages/react-native-web/src/exports/StyleSheet/modality.js b/packages/react-native-web/src/exports/StyleSheet/modality.js index f8030561..bb7b276c 100644 --- a/packages/react-native-web/src/exports/StyleSheet/modality.js +++ b/packages/react-native-web/src/exports/StyleSheet/modality.js @@ -28,7 +28,9 @@ const focusVisibleAttributeName = const rule = `:focus:not([${focusVisibleAttributeName}]){outline: none;}`; -const modality = styleElement => { +const modality = insertRule => { + insertRule(rule); + if (!canUseDOM) { return; } @@ -264,8 +266,6 @@ const modality = styleElement => { removeInitialPointerMoveListeners(); } - styleElement.sheet.insertRule(rule, 0); - document.addEventListener('keydown', onKeyDown, true); document.addEventListener('mousedown', onPointerDown, true); document.addEventListener('pointerdown', onPointerDown, true);