From 9cbe387d9ff8f05c2ebcd332ebf65d8d147c126e Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 21 Mar 2019 11:46:04 -0700 Subject: [PATCH] [fix] CSS insertion in Edge browser Edge browser throws `HierarchyRequestError` while inserting CSS rules into CSS Media Queries. Therefore, a different mechanism is required to control CSS order. This patch tracks the starting index of each group of CSS rules in the DOM style sheet. Fix #1300 Close #1302 --- .../__snapshots__/index-test.js.snap | 22 +--- .../createOrderedCSSStyleSheet-test.js.snap | 50 ++------ .../StyleSheet/createOrderedCSSStyleSheet.js | 117 ++++++++++-------- 3 files changed, 84 insertions(+), 105 deletions(-) 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 c1c2ab1f..b9015c7d 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 @@ -1,30 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`AppRegistry getApplication "getStyleElement" produces styles that are a function of rendering "element" 1`] = ` -"@media all { -[stylesheet-group=\\"0\\"]{} +"[stylesheet-group=\\"0\\"]{} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);} body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;} -} -@media all { [stylesheet-group=\\"0.1\\"]{} :focus:not([data-focusvisible-polyfill]){outline: none;} -} -@media all { [stylesheet-group=\\"1\\"]{} .css-view-1dbjc4n{-ms-flex-align:stretch;-ms-flex-direction:column;-ms-flex-negative:0;-ms-flex-preferred-size:auto;-webkit-align-items:stretch;-webkit-box-align:stretch;-webkit-box-direction:normal;-webkit-box-orient:vertical;-webkit-flex-basis:auto;-webkit-flex-direction:column;-webkit-flex-shrink:0;align-items:stretch;border:0 solid black;box-sizing:border-box;display:-webkit-box;display:-moz-box;display:-ms-flexbox;display:-webkit-flex;display:flex;flex-basis:auto;flex-direction:column;flex-shrink:0;margin-bottom:0px;margin-left:0px;margin-right:0px;margin-top:0px;min-height:0px;min-width:0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;position:relative;z-index:0;} -} -@media all { [stylesheet-group=\\"2\\"]{} .r-flex-13awgt0{-ms-flex-negative:1;-ms-flex-positive:1;-ms-flex-preferred-size:0%;-webkit-box-flex:1;-webkit-flex-basis:0%;-webkit-flex-grow:1;-webkit-flex-shrink:1;flex-basis:0%;flex-grow:1;flex-shrink:1;} -} -@media all { [stylesheet-group=\\"2.2\\"]{} .r-pointerEvents-12vffkv>*{pointer-events:auto;} -.r-pointerEvents-12vffkv{pointer-events:none!important;} -}" +.r-pointerEvents-12vffkv{pointer-events:none!important;}" `; exports[`AppRegistry getApplication returns "element" and "getStyleElement" 1`] = ` @@ -36,15 +26,11 @@ exports[`AppRegistry getApplication returns "element" and "getStyleElement" 1`] `; exports[`AppRegistry getApplication returns "element" and "getStyleElement" 2`] = ` -"" +:focus:not([data-focusvisible-polyfill]){outline: none;}" `; 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 index 15ab3746..5b07463d 100644 --- 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 @@ -3,81 +3,57 @@ exports[`createOrderedCSSStyleSheet #insert deduplication for same group 1`] = `""`; exports[`createOrderedCSSStyleSheet #insert deduplication for same group 2`] = ` -"@media all { -[stylesheet-group=\\"0\\"]{} -.one {} -}" +"[stylesheet-group=\\"0\\"]{} +.one {}" `; exports[`createOrderedCSSStyleSheet #insert deduplication for same group 3`] = ` -"@media all { -[stylesheet-group=\\"0\\"]{} -.one {} -}" +"[stylesheet-group=\\"0\\"]{} +.one {}" `; exports[`createOrderedCSSStyleSheet #insert insertion order for different groups 1`] = ` -"@media all { -[stylesheet-group=\\"1\\"]{} +"[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 {} -}" +.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 {} -}" +"[stylesheet-group=\\"0\\"]{} +.one {}" `; exports[`createOrderedCSSStyleSheet #insert insertion order for same group 3`] = ` -"@media all { -[stylesheet-group=\\"0\\"]{} +"[stylesheet-group=\\"0\\"]{} .one {} -.two {} -}" +.two {}" `; exports[`createOrderedCSSStyleSheet #insert insertion order for same group 4`] = ` -"@media all { -[stylesheet-group=\\"0\\"]{} +"[stylesheet-group=\\"0\\"]{} .one {} .two {} -.three {} -}" +.three {}" `; exports[`createOrderedCSSStyleSheet client-side hydration from SSR CSS 1`] = ` -"@media all { -[stylesheet-group=\\"1\\"] {} +"[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/createOrderedCSSStyleSheet.js b/packages/react-native-web/src/exports/StyleSheet/createOrderedCSSStyleSheet.js index b60c8553..6b08b6a0 100644 --- a/packages/react-native-web/src/exports/StyleSheet/createOrderedCSSStyleSheet.js +++ b/packages/react-native-web/src/exports/StyleSheet/createOrderedCSSStyleSheet.js @@ -7,7 +7,7 @@ * @flow strict-local */ -type Groups = { [key: number]: Array }; +type Groups = { [key: number]: { start: ?number, rules: Array } }; type Selectors = { [key: string]: boolean }; const slice = Array.prototype.slice; @@ -15,17 +15,17 @@ const slice = Array.prototype.slice; /** * Order-based insertion of CSS. * - * Each rule can be inserted (appended) into a numerically defined group. + * Each rule is associated with 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. + * Groups are implemented using marker rules. The selector of the first rule of + * each group is used only to encode the group number for hydration. An + * alternative implementation could rely on CSSMediaRule, allowing groups to be + * treated as a sub-sheet, but the Edge implementation of CSSMediaRule is + * broken. * 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. + * https://gist.github.com/necolas/aa0c37846ad6bd3b05b727b959e82674 */ export default function createOrderedCSSStyleSheet(sheet: ?CSSStyleSheet) { const groups: Groups = {}; @@ -35,59 +35,78 @@ export default function createOrderedCSSStyleSheet(sheet: ?CSSStyleSheet) { * 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] = []; - + let group; + slice.call(sheet.cssRules).forEach((cssRule, i) => { + const cssText = cssRule.cssText; // Create record of existing selectors and rules - slice.call(mediaRule.cssRules).forEach(rule => { - const selectorText = getSelectorText(rule.cssText); + if (cssText.indexOf('stylesheet-group') > -1) { + group = decodeGroupRule(cssRule); + groups[group] = { start: i, rules: [cssText] }; + } else { + const selectorText = getSelectorText(cssText); if (selectorText != null) { selectors[selectorText] = true; - groups[group].push(rule.cssText); + groups[group].rules.push(cssText); } - }); + } }); } + function sheetInsert(sheet, group, text) { + const orderedGroups = getOrderedGroups(groups); + const groupIndex = orderedGroups.indexOf(group); + const nextGroupIndex = groupIndex + 1; + const nextGroup = orderedGroups[nextGroupIndex]; + // Insert rule before the next group, or at the end of the stylesheet + const position = + nextGroup != null && groups[nextGroup].start != null + ? groups[nextGroup].start + : sheet.cssRules.length; + const isInserted = insertRuleAt(sheet, text, position); + + if (isInserted) { + // Set the starting index of the new group + if (groups[group].start == null) { + groups[group].start = position; + } + // Increment the starting index of all subsequent groups + for (let i = nextGroupIndex; i < orderedGroups.length; i += 1) { + const groupNumber = orderedGroups[i]; + const previousStart = groups[groupNumber].start; + groups[groupNumber].start = previousStart + 1; + } + } + + return isInserted; + } + 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); + const rules = groups[group].rules; + return rules.join('\n'); }) .join('\n'); }, /** - * Insert a rule into a media query in the style sheet + * Insert a rule into the style sheet */ - insert(cssText: string, group: number) { + insert(cssText: string, groupValue: number) { + const group = Number(groupValue); + // 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. + groups[group] = { start: null, rules: [markerRule] }; + // Update CSSOM. if (sheet != null) { - const groupIndex = getOrderedGroups(groups).indexOf(group); - insertRuleAt(sheet, createMediaRule(markerRule), groupIndex); + sheetInsert(sheet, group, markerRule); } } @@ -98,14 +117,14 @@ export default function createOrderedCSSStyleSheet(sheet: ?CSSStyleSheet) { if (selectorText != null && selectors[selectorText] == null) { // Update the internal records. selectors[selectorText] = true; - groups[group].push(cssText); - // Update CSSOM CSSMediaRule. + groups[group].rules.push(cssText); + // Update CSSOM. 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); + const isInserted = sheetInsert(sheet, group, cssText); + if (!isInserted) { + // Revert internal record change if a rule was rejected (e.g., + // unrecognized pseudo-selector) + groups[group].rules.pop(); } } } @@ -119,16 +138,12 @@ export default function createOrderedCSSStyleSheet(sheet: ?CSSStyleSheet) { * 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 decodeGroupRule(cssRule) { + return Number(cssRule.selectorText.split('"')[1]); } function getOrderedGroups(obj: { [key: number]: any }) { @@ -143,12 +158,14 @@ function getSelectorText(cssText) { return selector !== '' ? selector.replace(pattern, '$1') : null; } -function insertRuleAt(root, cssText: string, position: number) { +function insertRuleAt(root, cssText: string, position: number): boolean { try { // $FlowFixMe: Flow is missing CSSOM types needed to type 'root'. root.insertRule(cssText, position); + return true; } catch (e) { // JSDOM doesn't support `CSSSMediaRule#insertRule`. // Also ignore errors that occur from attempting to insert vendor-prefixed selectors. + return false; } }