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