From 550eca672f728970e6a13d6422365f68014e603e Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Mon, 5 Nov 2018 13:30:51 -0800 Subject: [PATCH] [change] Introduce static CSS base rules for core primitives This patch addresses 2 related issues: 1) Browser layout times in Chrome increase when elements use a lot of CSS class names. This begins to add up for larger trees. 2) React Native supports passing 'null' values for styles. This can remove base styles defined using 'StyleSheet' in the implementation of components like View and Text. Both of these issues can be avoided, and some runtime logic and computation removed, by moving the base styles to static CSS rules. Comparisons of the "benchmark" UI tests (which only render View) indicate that total times in Chrome are reduced by ~20% with almost all of those savings coming from a ~33% reduction in layout-related timings. To avoid style conflicts, static CSS rules are inserted before atomic CSS rules. The modality-related focus style is now inserted into the in-memory style sheet, making it available in SSR output. Ref #1136 Close #1165 --- .../__snapshots__/index-test.js.snap | 45 ++++------ .../exports/StyleSheet/StyleSheetManager.js | 14 ++- .../StyleSheet/StyleSheetValidation.js | 5 +- .../src/exports/StyleSheet/WebStyleSheet.js | 9 +- .../StyleSheetManager-test.js.snap | 5 +- .../__tests__/createReactDOMStyle-test.js | 57 +++---------- .../src/exports/StyleSheet/constants.js | 13 +++ .../exports/StyleSheet/createReactDOMStyle.js | 34 +------- .../src/exports/StyleSheet/createRuleBlock.js | 15 ++-- .../src/exports/StyleSheet/css.js | 53 ++++++++++++ .../src/exports/StyleSheet/initialRules.js | 29 ++++++- .../src/exports/StyleSheet/modality.js | 6 +- .../__snapshots__/index-test.js.snap | 14 ++- .../src/exports/Text/__tests__/index-test.js | 6 +- .../src/exports/Text/index.js | 63 ++++++++------ .../src/exports/TextInput/index.js | 16 ++-- .../src/exports/View/index.js | 48 +++++++---- .../__snapshots__/index-test.js.snap | 12 ++- .../createDOMProps/__tests__/index-test.js | 24 ++---- .../src/modules/createDOMProps/index.js | 85 +++++++++---------- .../src/modules/mapKeyValue/index.js | 14 --- 21 files changed, 298 insertions(+), 269 deletions(-) create mode 100644 packages/react-native-web/src/exports/StyleSheet/constants.js create mode 100644 packages/react-native-web/src/exports/StyleSheet/css.js delete mode 100644 packages/react-native-web/src/modules/mapKeyValue/index.js 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 b2f26204..928eba45 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 @@ -10,40 +10,17 @@ exports[`AppRegistry getApplication "getStyleElement" produces styles that are a `; exports[`AppRegistry getApplication "getStyleElement" produces styles that are a function of rendering "element": CSS for an unstyled app 1`] = ` -"@media all{ +":focus:not([data-rn-focusvisible-x92cna]){outline: none;} +@media all{ 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;} +.rn-reset{background-color:transparent;color:inherit;font:inherit;list-style:none;margin:0;text-align:inherit;text-decoration:none;} +.rn-pointer{cursor:pointer;} } .rn-pointerEvents-12vffkv > *{pointer-events:auto} .rn-pointerEvents-12vffkv{pointer-events:none !important} -.rn-alignItems-1oszu61{-ms-flex-align:stretch;-webkit-align-items:stretch;-webkit-box-align:stretch;align-items:stretch} -.rn-borderTopStyle-1efd50x{border-top-style:solid} -.rn-borderRightStyle-14skgim{border-right-style:solid} -.rn-borderBottomStyle-rull8r{border-bottom-style:solid} -.rn-borderLeftStyle-mm0ijv{border-left-style:solid} -.rn-borderTopWidth-13yce4e{border-top-width:0px} -.rn-borderRightWidth-fnigne{border-right-width:0px} -.rn-borderBottomWidth-ndvcnb{border-bottom-width:0px} -.rn-borderLeftWidth-gxnn5r{border-left-width:0px} -.rn-boxSizing-deolkf{box-sizing:border-box} -.rn-display-6koalj{display:-webkit-box;display:-moz-box;display:-ms-flexbox;display:-webkit-flex;display:flex} -.rn-flexShrink-1qe8dj5{-ms-flex-negative:0;-webkit-flex-shrink:0;flex-shrink:0} -.rn-flexBasis-1mlwlqe{-ms-flex-preferred-size:auto;-webkit-flex-basis:auto;flex-basis:auto} -.rn-flexDirection-eqz5dr{-ms-flex-direction:column;-webkit-box-direction:normal;-webkit-box-orient:vertical;-webkit-flex-direction:column;flex-direction:column} -.rn-marginTop-1mnahxq{margin-top:0px} -.rn-marginRight-61z16t{margin-right:0px} -.rn-marginBottom-p1pxzi{margin-bottom:0px} -.rn-marginLeft-11wrixw{margin-left:0px} -.rn-minHeight-ifefl9{min-height:0px} -.rn-minWidth-bcqeeo{min-width:0px} -.rn-paddingTop-wk8lta{padding-top:0px} -.rn-paddingRight-9aemit{padding-right:0px} -.rn-paddingBottom-1mdbw0j{padding-bottom:0px} -.rn-paddingLeft-gy4na3{padding-left:0px} -.rn-position-bnwqim{position:relative} -.rn-zIndex-1lgpqti{z-index:0} .rn-flexGrow-16y2uox{-ms-flex-positive:1;-webkit-box-flex:1;-webkit-flex-grow:1;flex-grow:1} .rn-flexShrink-1wbh5a2{-ms-flex-negative:1;-webkit-flex-shrink:1;flex-shrink:1} .rn-flexBasis-1ro0kt6{-ms-flex-preferred-size:0%;-webkit-flex-basis:0%;flex-basis:0%}" @@ -58,10 +35,20 @@ exports[`AppRegistry getApplication returns "element" and "getStyleElement" 1`] `; exports[`AppRegistry getApplication returns "element" and "getStyleElement" 2`] = ` -"" +.rn-reset{background-color:transparent;color:inherit;font:inherit;list-style:none;margin:0;text-align:inherit;text-decoration:none;} +.rn-pointer{cursor:pointer;} +} +.rn-ui-textinput-11ngujt{-moz-appearance:textfield;-webkit-appearance:none;background-color:transparent;border-radius:0px;border:0 solid black;box-sizing:border-box;font-family:14px System;padding:0px;resize:none} +.rn-ui-textSingleLine-1xjj19i{max-width:100%;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important} +.rn-ui-textHasAncestor-z2plr{color:inherit;font:inherit;text-decoration:inherit;white-space:inherit} +.rn-ui-textIsRoot-gw3a6r{color:black;font:normal 14px system-ui, -apple-system, BlinkMacSystemFont, \\"Segoe UI\\", Roboto, Ubuntu, \\"Helvetica Neue\\", sans-serif;text-decoration:none;white-space:pre-wrap} +.rn-ui-text-1ntzlq4{background-color:transparent;border-width:0px;box-sizing:border-box;display:inline;margin:0px;padding:0px;text-align:inherit;word-wrap:break-word} +.rn-ui-hitSlop-14nrb4u{bottom:0px;left:0px;position:absolute;right:0px;top:0px;z-index:-1} +.rn-ui-view-15pvbv0{-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;background-color:transparent;border:0 solid black;box-sizing:border-box;color:inherit;display:-webkit-box;display:-moz-box;display:-ms-flexbox;display:-webkit-flex;display:flex;flex-basis:auto;flex-direction:column;flex-shrink:0;font:inherit;list-style:none;margin:0px;min-height:0px;min-width:0px;padding:0px;position:relative;text-align:inherit;text-decoration:none;z-index:0}" `; diff --git a/packages/react-native-web/src/exports/StyleSheet/StyleSheetManager.js b/packages/react-native-web/src/exports/StyleSheet/StyleSheetManager.js index 757cafc5..fbfe6a8d 100644 --- a/packages/react-native-web/src/exports/StyleSheet/StyleSheetManager.js +++ b/packages/react-native-web/src/exports/StyleSheet/StyleSheetManager.js @@ -15,9 +15,9 @@ import WebStyleSheet from './WebStyleSheet'; const emptyObject = {}; const STYLE_ELEMENT_ID = 'react-native-stylesheet'; -const createClassName = (prop, value) => { - const hashed = hash(prop + normalizeValue(value)); - return process.env.NODE_ENV !== 'production' ? `rn-${prop}-${hashed}` : `rn-${hashed}`; +const createClassName = (name, value) => { + const hashed = hash(name + normalizeValue(value)); + return process.env.NODE_ENV !== 'production' ? `rn-${name}-${hashed}` : `rn-${hashed}`; }; const normalizeValue = value => (typeof value === 'object' ? JSON.stringify(value) : value); @@ -69,6 +69,14 @@ export default class StyleSheetManager { return className; } + injectRule(name, body: string): void { + const className = createClassName(`ui-${name}`, body); + const rule = `.${className}{${body}}`; + // insert after the reset + modality but before atomic css + this._sheet.insertRuleOnce(rule, 2); + return className; + } + _addToCache(className, prop, value) { const cache = this._cache; if (!cache.byProp[prop]) { diff --git a/packages/react-native-web/src/exports/StyleSheet/StyleSheetValidation.js b/packages/react-native-web/src/exports/StyleSheet/StyleSheetValidation.js index b94e3aed..29ecb37a 100644 --- a/packages/react-native-web/src/exports/StyleSheet/StyleSheetValidation.js +++ b/packages/react-native-web/src/exports/StyleSheet/StyleSheetValidation.js @@ -103,10 +103,7 @@ StyleSheetValidation.addValidStylePropTypes({ objectFit: oneOf(['fill', 'contain', 'cover', 'none', 'scale-down']), objectPosition: string, pointerEvents: string, - tableLayout: string, - /* @private */ - MozAppearance: string, - WebkitAppearance: string + tableLayout: string }); export default StyleSheetValidation; diff --git a/packages/react-native-web/src/exports/StyleSheet/WebStyleSheet.js b/packages/react-native-web/src/exports/StyleSheet/WebStyleSheet.js index 839c73e4..08415df4 100644 --- a/packages/react-native-web/src/exports/StyleSheet/WebStyleSheet.js +++ b/packages/react-native-web/src/exports/StyleSheet/WebStyleSheet.js @@ -30,12 +30,13 @@ export default class WebStyleSheet { } if (domStyleElement) { - modality(domStyleElement); // $FlowFixMe this._sheet = domStyleElement.sheet; this._textContent = domStyleElement.textContent; } } + + modality((rule) => this.insertRuleOnce(rule, 0)); } containsRule(rule: string): boolean { @@ -49,7 +50,11 @@ export default class WebStyleSheet { insertRuleOnce(rule: string, position: ?number) { // Reduce chance of duplicate rules if (!this.containsRule(rule)) { - this._cssRules.push(rule); + if (position != null) { + this._cssRules.splice(position, 0, rule); + } else { + this._cssRules.push(rule); + } // Check whether a rule was part of any prerendered styles (textContent // doesn't include styles injected via 'insertRule') diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/StyleSheetManager-test.js.snap b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/StyleSheetManager-test.js.snap index 3142a035..31c22f58 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/StyleSheetManager-test.js.snap +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/StyleSheetManager-test.js.snap @@ -5,11 +5,14 @@ exports[`StyleSheet/StyleSheetManager getClassName 1`] = `undefined`; exports[`StyleSheet/StyleSheetManager getStyleSheet 1`] = ` Object { "id": "react-native-stylesheet", - "textContent": "@media all{ + "textContent": ":focus:not([data-rn-focusvisible-x92cna]){outline: none;} +@media all{ 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;} +.rn-reset{background-color:transparent;color:inherit;font:inherit;list-style:none;margin:0;text-align:inherit;text-decoration:none;} +.rn-pointer{cursor:pointer;} } .rn---test-property-ax3bxi{--test-property:test-value}", } diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/createReactDOMStyle-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/createReactDOMStyle-test.js index 104aaf85..d16b7c8a 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/createReactDOMStyle-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/createReactDOMStyle-test.js @@ -39,44 +39,17 @@ describe('StyleSheet/createReactDOMStyle', () => { expect(createReactDOMStyle(style)).toMatchSnapshot(); }); - describe('borderWidth styles', () => { - test('defaults to 0 when "null"', () => { - expect(createReactDOMStyle({ borderWidth: null })).toEqual({ - borderTopWidth: '0px', - borderRightWidth: '0px', - borderBottomWidth: '0px', - borderLeftWidth: '0px' - }); - expect(createReactDOMStyle({ borderWidth: 2, borderRightWidth: null })).toEqual({ - borderTopWidth: '2px', - borderRightWidth: '0px', - borderBottomWidth: '2px', - borderLeftWidth: '2px' - }); - }); - }); - describe('flexbox styles', () => { - test('flex defaults', () => { - expect(createReactDOMStyle({ display: 'flex' })).toEqual({ - display: 'flex', - flexShrink: 0, - flexBasis: 'auto' - }); - }); - test('flex: -1', () => { - expect(createReactDOMStyle({ display: 'flex', flex: -1 })).toEqual({ - display: 'flex', + expect(createReactDOMStyle({ flex: -1 })).toEqual({ + flexBasis: 'auto', flexGrow: 0, - flexShrink: 1, - flexBasis: 'auto' + flexShrink: 1 }); }); test('flex: 0', () => { - expect(createReactDOMStyle({ display: 'flex', flex: 0 })).toEqual({ - display: 'flex', + expect(createReactDOMStyle({ flex: 0 })).toEqual({ flexGrow: 0, flexShrink: 0, flexBasis: '0%' @@ -84,8 +57,7 @@ describe('StyleSheet/createReactDOMStyle', () => { }); test('flex: 1', () => { - expect(createReactDOMStyle({ display: 'flex', flex: 1 })).toEqual({ - display: 'flex', + expect(createReactDOMStyle({ flex: 1 })).toEqual({ flexGrow: 1, flexShrink: 1, flexBasis: '0%' @@ -93,8 +65,7 @@ describe('StyleSheet/createReactDOMStyle', () => { }); test('flex: 10', () => { - expect(createReactDOMStyle({ display: 'flex', flex: 10 })).toEqual({ - display: 'flex', + expect(createReactDOMStyle({ flex: 10 })).toEqual({ flexGrow: 10, flexShrink: 1, flexBasis: '0%' @@ -103,15 +74,12 @@ describe('StyleSheet/createReactDOMStyle', () => { test('flexBasis overrides', () => { // is flex-basis applied? - expect(createReactDOMStyle({ display: 'flex', flexBasis: '25%' })).toEqual({ - display: 'flex', - flexShrink: 0, + expect(createReactDOMStyle({ flexBasis: '25%' })).toEqual({ flexBasis: '25%' }); // can flex-basis override the 'flex' expansion? - expect(createReactDOMStyle({ display: 'flex', flex: 1, flexBasis: '25%' })).toEqual({ - display: 'flex', + expect(createReactDOMStyle({ flex: 1, flexBasis: '25%' })).toEqual({ flexGrow: 1, flexShrink: 1, flexBasis: '25%' @@ -120,15 +88,12 @@ describe('StyleSheet/createReactDOMStyle', () => { test('flexShrink overrides', () => { // is flex-shrink applied? - expect(createReactDOMStyle({ display: 'flex', flexShrink: 1 })).toEqual({ - display: 'flex', - flexShrink: 1, - flexBasis: 'auto' + expect(createReactDOMStyle({ flexShrink: 1 })).toEqual({ + flexShrink: 1 }); // can flex-shrink override the 'flex' expansion? - expect(createReactDOMStyle({ display: 'flex', flex: 1, flexShrink: 2 })).toEqual({ - display: 'flex', + expect(createReactDOMStyle({ flex: 1, flexShrink: 2 })).toEqual({ flexGrow: 1, flexShrink: 2, flexBasis: '0%' diff --git a/packages/react-native-web/src/exports/StyleSheet/constants.js b/packages/react-native-web/src/exports/StyleSheet/constants.js new file mode 100644 index 00000000..619bc315 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/constants.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2016-present, 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 + */ + +export const monospaceFontStack = 'monospace, monospace'; + +export const systemFontStack = + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", sans-serif'; diff --git a/packages/react-native-web/src/exports/StyleSheet/createReactDOMStyle.js b/packages/react-native-web/src/exports/StyleSheet/createReactDOMStyle.js index 1eb2bed3..0598ba46 100644 --- a/packages/react-native-web/src/exports/StyleSheet/createReactDOMStyle.js +++ b/packages/react-native-web/src/exports/StyleSheet/createReactDOMStyle.js @@ -7,6 +7,7 @@ * @noflow */ +import { monospaceFontStack, systemFontStack } from './constants'; import normalizeColor from '../../modules/normalizeColor'; import normalizeValue from './normalizeValue'; import resolveShadowValue from './resolveShadowValue'; @@ -54,18 +55,6 @@ const colorProps = { color: true }; -const borderWidthProps = { - borderWidth: true, - borderTopWidth: true, - borderRightWidth: true, - borderBottomWidth: true, - borderLeftWidth: true -}; - -const monospaceFontStack = 'monospace, monospace'; -const systemFontStack = - 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", sans-serif'; - const alphaSortProps = propsArray => propsArray.sort((a, b) => { if (a < b) { @@ -167,12 +156,6 @@ const createReducer = (style, styleProps) => { return (resolvedStyle, prop) => { let value = normalizeValue(prop, style[prop]); - // Make sure the default border width is explicitly set to '0' to avoid - // falling back to any unwanted user-agent styles. - if (borderWidthProps[prop]) { - value = value == null ? normalizeValue(null, 0) : value; - } - // Normalize color values if (colorProps[prop]) { value = normalizeColor(value); @@ -203,21 +186,6 @@ const createReducer = (style, styleProps) => { break; } - case 'display': { - resolvedStyle.display = value; - // A flex container in React Native has these defaults which should be - // set only if there is no otherwise supplied flex style. - if (style.display === 'flex' && style.flex == null) { - if (style.flexShrink == null) { - resolvedStyle.flexShrink = 0; - } - if (style.flexBasis == null) { - resolvedStyle.flexBasis = 'auto'; - } - } - break; - } - // The 'flex' property value in React Native must be a positive integer, // 0, or -1. case 'flex': { diff --git a/packages/react-native-web/src/exports/StyleSheet/createRuleBlock.js b/packages/react-native-web/src/exports/StyleSheet/createRuleBlock.js index 34e5b5c3..d0a89a2f 100644 --- a/packages/react-native-web/src/exports/StyleSheet/createRuleBlock.js +++ b/packages/react-native-web/src/exports/StyleSheet/createRuleBlock.js @@ -8,7 +8,6 @@ */ import hyphenateStyleName from 'hyphenate-style-name'; -import mapKeyValue from '../../modules/mapKeyValue'; import normalizeValue from './normalizeValue'; import prefixStyles from '../../modules/prefixStyles'; @@ -27,9 +26,15 @@ const createDeclarationString = (prop, val) => { * createRuleBlock({ width: 20, color: 'blue' }); * // => 'color:blue;width:20px' */ -const createRuleBlock = style => - mapKeyValue(prefixStyles(style), createDeclarationString) - .sort() - .join(';'); +const createRuleBlock = style => { + const prefixedStyle = prefixStyles(style); + return ( + Object.keys(prefixedStyle) + .map(prop => createDeclarationString(prop, prefixedStyle[prop])) + // put short-form and vendor prefixed properties first + .sort() + .join(';') + ); +}; export default createRuleBlock; diff --git a/packages/react-native-web/src/exports/StyleSheet/css.js b/packages/react-native-web/src/exports/StyleSheet/css.js new file mode 100644 index 00000000..bda4bec9 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/css.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2016-present, Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + */ + +import createRuleBlock from './createRuleBlock'; +import styleResolver from './styleResolver'; +import { systemFontStack } from './constants'; + +const fontFamilyProperties = ['font', 'fontFamily']; +/** + * A simple (and dangerous) CSS system. + * The order of CSS rule insertion is not guaranteed. + * Avoiding combining 2 or more classes that modify the same property. + */ +const css = { + /** + * const classes = css.create({ base: {}, extra: {} }) + */ + create(rules) { + const result = {}; + Object.keys(rules).forEach(key => { + const rule = rules[key]; + fontFamilyProperties.forEach(prop => { + const value = rule[prop]; + if (value && value.indexOf('System') > -1) { + rule[prop] = value.replace('System', systemFontStack); + } + }); + const cssRule = createRuleBlock(rule); + const className = styleResolver.styleSheetManager.injectRule(key, cssRule); + result[key] = className; + }); + return result; + }, + /** + * css.combine(classes.base, classes.extra) + */ + combine(...args) { + return args.reduce((className, value) => { + if (value) { + className += className.length > 0 ? ' ' + value : value; + } + return className; + }, ''); + } +}; + +export default css; diff --git a/packages/react-native-web/src/exports/StyleSheet/initialRules.js b/packages/react-native-web/src/exports/StyleSheet/initialRules.js index ebb289af..1ca82cc4 100644 --- a/packages/react-native-web/src/exports/StyleSheet/initialRules.js +++ b/packages/react-native-web/src/exports/StyleSheet/initialRules.js @@ -12,13 +12,34 @@ 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);}', + 'html{' + + '-ms-text-size-adjust:100%;' + + '-webkit-text-size-adjust:100%;' + + '-webkit-tap-highlight-color:rgba(0,0,0,0);' + + '}', 'body{margin:0;}', // minimal form pseudo-element reset '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;}' + '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;' + + '}', + // Reset styles for heading, link, and list DOM elements + '.rn-reset{' + + 'background-color:transparent;' + + 'color:inherit;' + + 'font:inherit;' + + 'list-style:none;' + + 'margin:0;' + + 'text-align:inherit;' + + 'text-decoration:none;' + + '}', + // For pressable elements + '.rn-pointer{cursor:pointer;}' ]; const reset = [safeRule(resets.join('\n'))]; 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); diff --git a/packages/react-native-web/src/exports/Text/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/Text/__tests__/__snapshots__/index-test.js.snap index 4ff86733..5fa4b210 100644 --- a/packages/react-native-web/src/exports/Text/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/exports/Text/__tests__/__snapshots__/index-test.js.snap @@ -1,8 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`components/Text prop "children" 1`] = ` + +`; + exports[`components/Text prop "onPress" 1`] = `
`; exports[`components/Text prop "selectable" 2`] = `
`; diff --git a/packages/react-native-web/src/exports/Text/__tests__/index-test.js b/packages/react-native-web/src/exports/Text/__tests__/index-test.js index 0873d8ae..829e5993 100644 --- a/packages/react-native-web/src/exports/Text/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Text/__tests__/index-test.js @@ -29,9 +29,9 @@ describe('components/Text', () => { }); test('prop "children"', () => { - const children = ; - const component = shallow(); - expect(component.contains(children)).toEqual(true); + const children = ; + const component = render(); + expect(component.children()).toMatchSnapshot(); }); test('prop "numberOfLines"', () => {}); diff --git a/packages/react-native-web/src/exports/Text/index.js b/packages/react-native-web/src/exports/Text/index.js index 0610649c..543a5351 100644 --- a/packages/react-native-web/src/exports/Text/index.js +++ b/packages/react-native-web/src/exports/Text/index.js @@ -13,6 +13,7 @@ import applyNativeMethods from '../../modules/applyNativeMethods'; import { bool } from 'prop-types'; import { Component } from 'react'; import createElement from '../createElement'; +import css from '../StyleSheet/css'; import StyleSheet from '../StyleSheet'; import TextPropTypes from './TextPropTypes'; @@ -57,7 +58,7 @@ class Text extends Component<*> { ...otherProps } = this.props; - const { isInAParentText } = this.context; + const { isInAParentText: hasTextAncestor } = this.context; if (onPress) { otherProps.accessible = true; @@ -65,18 +66,20 @@ class Text extends Component<*> { otherProps.onKeyDown = this._createEnterHandler(onPress); } - // allow browsers to automatically infer the language writing direction - otherProps.dir = dir !== undefined ? dir : 'auto'; + otherProps.className = css.combine( + classes.text, + hasTextAncestor ? classes.textHasAncestor : classes.textIsRoot, + numberOfLines === 1 && classes.textSingleLine + ); otherProps.style = [ - styles.initial, - this.context.isInAParentText === true && styles.isInAParentText, style, selectable === false && styles.notSelectable, - numberOfLines === 1 && styles.singleLineStyle, onPress && styles.pressable ]; + // allow browsers to automatically infer the language writing direction + otherProps.dir = dir !== undefined ? dir : 'auto'; - const component = isInAParentText ? 'span' : 'div'; + const component = hasTextAncestor ? 'span' : 'div'; return createElement(component, otherProps); } @@ -97,41 +100,45 @@ class Text extends Component<*> { } } -const styles = StyleSheet.create({ - initial: { +const classes = css.create({ + text: { + backgroundColor: 'transparent', borderWidth: 0, boxSizing: 'border-box', - color: 'inherit', display: 'inline', - fontFamily: 'System', - fontSize: 14, - fontStyle: 'inherit', - fontVariant: ['inherit'], - fontWeight: 'inherit', - lineHeight: 'inherit', margin: 0, padding: 0, - textDecorationLine: 'none', - whiteSpace: 'pre-wrap', + textAlign: 'inherit', wordWrap: 'break-word' }, - isInAParentText: { - // inherit parent font styles - fontFamily: 'inherit', - fontSize: 'inherit', + textIsRoot: { + color: 'black', + font: 'normal 14px System', + textDecoration: 'none', + whiteSpace: 'pre-wrap' + }, + textHasAncestor: { + color: 'inherit', + font: 'inherit', + textDecoration: 'inherit', whiteSpace: 'inherit' }, + // "!important" is used to prevent essential styles from being overridden + // by merged styles + textSingleLine: { + maxWidth: '100%', + overflow: 'hidden !important', + textOverflow: 'ellipsis !important', + whiteSpace: 'nowrap !important' + } +}); + +const styles = StyleSheet.create({ notSelectable: { userSelect: 'none' }, pressable: { cursor: 'pointer' - }, - singleLineStyle: { - maxWidth: '100%', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' } }); diff --git a/packages/react-native-web/src/exports/TextInput/index.js b/packages/react-native-web/src/exports/TextInput/index.js index ef2f37b9..255f2038 100644 --- a/packages/react-native-web/src/exports/TextInput/index.js +++ b/packages/react-native-web/src/exports/TextInput/index.js @@ -14,8 +14,8 @@ import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment'; import { Component } from 'react'; import ColorPropType from '../ColorPropType'; import createElement from '../createElement'; +import css from '../StyleSheet/css'; import findNodeHandle from '../findNodeHandle'; -import StyleSheet from '../StyleSheet'; import StyleSheetPropType from '../../modules/StyleSheetPropType'; import TextInputStylePropTypes from './TextInputStylePropTypes'; import TextInputState from '../../modules/TextInputState'; @@ -254,6 +254,7 @@ class TextInput extends Component<*> { Object.assign(otherProps, { autoCorrect: autoCorrect ? 'on' : 'off', + className: classes.textinput, dir: 'auto', onBlur: normalizeEventHandler(this._handleBlur), onChange: normalizeEventHandler(this._handleChange), @@ -264,7 +265,7 @@ class TextInput extends Component<*> { readOnly: !editable, ref: this._setNode, spellCheck: spellCheck != null ? spellCheck : autoCorrect, - style: [styles.initial, style] + style }); if (multiline) { @@ -388,18 +389,15 @@ class TextInput extends Component<*> { }; } -const styles = StyleSheet.create({ - initial: { +const classes = css.create({ + textinput: { MozAppearance: 'textfield', WebkitAppearance: 'none', backgroundColor: 'transparent', - borderColor: 'black', + border: '0 solid black', borderRadius: 0, - borderStyle: 'solid', - borderWidth: 0, boxSizing: 'border-box', - fontFamily: 'System', - fontSize: 14, + fontFamily: '14px System', padding: 0, resize: 'none' } diff --git a/packages/react-native-web/src/exports/View/index.js b/packages/react-native-web/src/exports/View/index.js index 2407c1fc..c6b3b1bb 100644 --- a/packages/react-native-web/src/exports/View/index.js +++ b/packages/react-native-web/src/exports/View/index.js @@ -10,6 +10,7 @@ import applyLayout from '../../modules/applyLayout'; import applyNativeMethods from '../../modules/applyNativeMethods'; import { bool } from 'prop-types'; import createElement from '../createElement'; +import css from '../StyleSheet/css'; import filterSupportedProps from './filterSupportedProps'; import invariant from 'fbjs/lib/invariant'; import StyleSheet from '../StyleSheet'; @@ -51,14 +52,18 @@ class View extends Component { const { isInAParentText } = this.context; + supportedProps.className = classes.view; supportedProps.style = StyleSheet.compose( - styles.initial, - StyleSheet.compose(isInAParentText && styles.inline, this.props.style) + isInAParentText && styles.inline, + this.props.style ); if (hitSlop) { const hitSlopStyle = calculateHitSlopStyle(hitSlop); - const hitSlopChild = createElement('span', { style: [styles.hitSlop, hitSlopStyle] }); + const hitSlopChild = createElement('span', { + className: classes.hitslop, + style: hitSlopStyle + }); supportedProps.children = React.Children.toArray([hitSlopChild, supportedProps.children]); } @@ -66,32 +71,45 @@ class View extends Component { } } -const styles = StyleSheet.create({ - // https://github.com/facebook/css-layout#default-values - initial: { +const classes = css.create({ + view: { alignItems: 'stretch', - borderWidth: 0, - borderStyle: 'solid', + border: '0 solid black', boxSizing: 'border-box', display: 'flex', + flexBasis: 'auto', flexDirection: 'column', + flexShrink: 0, margin: 0, + minHeight: 0, + minWidth: 0, padding: 0, position: 'relative', zIndex: 0, - // fix flexbox bugs - minHeight: 0, - minWidth: 0 - }, - inline: { - display: 'inline-flex' + // resets for if View is rendered as a link or list DOM element + backgroundColor: 'transparent', + color: 'inherit', + font: 'inherit', + listStyle: 'none', + textAlign: 'inherit', + textDecoration: 'none' }, // this zIndex-ordering positions the hitSlop above the View but behind // its children hitSlop: { - ...StyleSheet.absoluteFillObject, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, zIndex: -1 } }); +const styles = StyleSheet.create({ + inline: { + display: 'inline-flex' + } +}); + export default applyLayout(applyNativeMethods(View)); diff --git a/packages/react-native-web/src/modules/createDOMProps/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/modules/createDOMProps/__tests__/__snapshots__/index-test.js.snap index c392263b..742ae014 100644 --- a/packages/react-native-web/src/modules/createDOMProps/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/modules/createDOMProps/__tests__/__snapshots__/index-test.js.snap @@ -2,10 +2,14 @@ exports[`modules/createDOMProps includes "rel" values for "a" elements (to securely open external links) 1`] = `" noopener noreferrer"`; -exports[`modules/createDOMProps includes cursor style for "button" role 1`] = `"rn-cursor-1loqt21"`; +exports[`modules/createDOMProps includes base reset style for browser-styled elements 1`] = `"rn-reset"`; -exports[`modules/createDOMProps includes reset styles for "a" elements 1`] = `"rn-backgroundColor-1niwhzg rn-color-homxoj rn-textDecoration-bauka4"`; +exports[`modules/createDOMProps includes base reset style for browser-styled elements 2`] = `"rn-reset"`; -exports[`modules/createDOMProps includes reset styles for "button" elements 1`] = `"rn-appearance-30o5oe rn-backgroundColor-1niwhzg rn-color-homxoj rn-fontFamily-poiln3 rn-fontSize-7cikom rn-fontStyle-o11vmf rn-fontVariant-ebii48 rn-fontWeight-gul640 rn-lineHeight-t9a87b rn-textAlign-1ttztb7"`; +exports[`modules/createDOMProps includes base reset style for browser-styled elements 3`] = `"rn-reset"`; -exports[`modules/createDOMProps includes reset styles for "ul" elements 1`] = `"rn-listStyle-1ebb2ja"`; +exports[`modules/createDOMProps includes base reset style for browser-styled elements 4`] = `"rn-reset"`; + +exports[`modules/createDOMProps includes cursor style for pressable roles 1`] = `"rn-pointer"`; + +exports[`modules/createDOMProps includes cursor style for pressable roles 2`] = `"rn-pointer"`; diff --git a/packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js b/packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js index 12d0c06a..0946674b 100644 --- a/packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js +++ b/packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js @@ -193,23 +193,15 @@ describe('modules/createDOMProps', () => { expect(props.rel).toMatchSnapshot(); }); - test('includes reset styles for "a" elements', () => { - const props = createDOMProps('a'); - expect(props.className).toMatchSnapshot(); + test('includes cursor style for pressable roles', () => { + expect(createDOMProps('span', { accessibilityRole: 'link' }).className).toMatchSnapshot(); + expect(createDOMProps('span', { accessibilityRole: 'button' }).className).toMatchSnapshot(); }); - test('includes reset styles for "button" elements', () => { - const props = createDOMProps('button'); - expect(props.className).toMatchSnapshot(); - }); - - test('includes cursor style for "button" role', () => { - const props = createDOMProps('span', { accessibilityRole: 'button' }); - expect(props.className).toMatchSnapshot(); - }); - - test('includes reset styles for "ul" elements', () => { - const props = createDOMProps('ul'); - expect(props.className).toMatchSnapshot(); + test('includes base reset style for browser-styled elements', () => { + expect(createDOMProps('a').className).toMatchSnapshot(); + expect(createDOMProps('button').className).toMatchSnapshot(); + expect(createDOMProps('li').className).toMatchSnapshot(); + expect(createDOMProps('ul').className).toMatchSnapshot(); }); }); diff --git a/packages/react-native-web/src/modules/createDOMProps/index.js b/packages/react-native-web/src/modules/createDOMProps/index.js index adee2aa9..ea67b8a0 100644 --- a/packages/react-native-web/src/modules/createDOMProps/index.js +++ b/packages/react-native-web/src/modules/createDOMProps/index.js @@ -13,40 +13,6 @@ import styleResolver from '../../exports/StyleSheet/styleResolver'; const emptyObject = {}; -const resetStyles = StyleSheet.create({ - ariaButton: { - cursor: 'pointer' - }, - button: { - appearance: 'none', - backgroundColor: 'transparent', - color: 'inherit', - fontFamily: 'inherit', - fontSize: 'inherit', - fontStyle: 'inherit', - fontVariant: ['inherit'], - fontWeight: 'inherit', - lineHeight: 'inherit', - textAlign: 'inherit' - }, - heading: { - fontFamily: 'inherit', - fontSize: 'inherit', - fontStyle: 'inherit', - fontVariant: ['inherit'], - fontWeight: 'inherit', - lineHeight: 'inherit' - }, - link: { - backgroundColor: 'transparent', - color: 'inherit', - textDecorationLine: 'none' - }, - list: { - listStyle: 'none' - } -}); - const pointerEventsStyles = StyleSheet.create({ auto: { pointerEvents: 'auto' @@ -129,6 +95,8 @@ const createDOMProps = (component, props, styleResolver) => { importantForAccessibility !== 'no-hide-descendants'; if ( role === 'link' || + component === 'a' || + component === 'button' || component === 'input' || component === 'select' || component === 'textarea' @@ -152,30 +120,53 @@ const createDOMProps = (component, props, styleResolver) => { // STYLE // Resolve React Native styles to optimized browser equivalent - const reactNativeStyle = [ - component === 'a' && resetStyles.link, - component === 'button' && resetStyles.button, - role === 'heading' && resetStyles.heading, - component === 'ul' && resetStyles.list, - role === 'button' && !disabled && resetStyles.ariaButton, + const reactNativeStyle = StyleSheet.compose( pointerEvents && pointerEventsStyles[pointerEvents], - providedStyle, - placeholderTextColor && { placeholderTextColor } - ]; + StyleSheet.compose( + providedStyle, + placeholderTextColor && { placeholderTextColor } + ) + ); const { className, style } = styleResolver(reactNativeStyle); - if (className && className.constructor === String) { - domProps.className = props.className ? `${props.className} ${className}` : className; - } if (style) { domProps.style = style; } + // CLASSNAME + // Apply static style resets + let c; + // style interactive elements for mouse and mobile browsers + if ((role === 'button' || role === 'link') && !disabled) { + c = 'rn-pointer'; + } + // style reset various elements (not all are used internally) + if ( + component === 'a' || + component === 'button' || + component === 'li' || + component === 'ul' || + role === 'heading' + ) { + c = 'rn-reset' + (c != null ? ' ' + c : ''); + } + // style from createElement use + if (props.className != null) { + c = props.className + (c != null ? ' ' + c : ''); + } + // style from React Native StyleSheets + if (className != null && className !== '') { + c = (c != null ? c + ' ' : '') + className; + } + if (c != null) { + domProps.className = c; + } + // OTHER // Native element ID if (nativeID && nativeID.constructor === String) { domProps.id = nativeID; } - // Link security and automation test ids + // Link security if (component === 'a' && domProps.target === '_blank') { domProps.rel = `${domProps.rel || ''} noopener noreferrer`; } diff --git a/packages/react-native-web/src/modules/mapKeyValue/index.js b/packages/react-native-web/src/modules/mapKeyValue/index.js deleted file mode 100644 index f019a3c6..00000000 --- a/packages/react-native-web/src/modules/mapKeyValue/index.js +++ /dev/null @@ -1,14 +0,0 @@ -const hasOwnProperty = Object.prototype.hasOwnProperty; - -const mapKeyValue = (obj, fn) => { - const result = []; - for (const key in obj) { - if (hasOwnProperty.call(obj, key)) { - const r = fn(key, obj[key]); - r && result.push(r); - } - } - return result; -}; - -export default mapKeyValue;