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);