From 240cf7e05f05a622621d0ee5f58380a35d28ab44 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Wed, 24 Jan 2018 18:34:29 -0800 Subject: [PATCH] [change] AppRegistry and StyleSheet APIs to fix SSR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSR was not working correctly. Styles would accumulate in the style cache and the styles returned for each 'getApplication' call would not represent the styles needed to render a given tree, but rather all the styles needed to render every tree that has been rendered by that server instance. This is now fixed by reinitializing the style resolver after a call to 'getStyleSheet' on the server. The return type of 'AppRegistry.getApplication' is changed – { element, getStyleElement }. The 'getStyleElement' function must be called after the component tree has been rendered. Note that if 'getStyleElement' is not called for a response, then its styles may leak into the next response's styles (but will not affect the UX). This patch also removes the 'StyleSheet.getStyleSheets' (web-only) API and requires SSR to be done using 'AppRegistry.getApplication'. Fix #778 --- .../renderApplication-test.js.snap | 60 ++++++++++++++++++- .../__tests__/renderApplication-test.js | 56 ++++++++++++++--- .../exports/AppRegistry/renderApplication.js | 13 ++-- .../StyleSheet/ReactNativeStyleResolver.js | 41 ++++++++----- .../exports/StyleSheet/StyleSheetManager.js | 16 +++-- .../src/exports/StyleSheet/WebStyleSheet.js | 38 ++++++------ .../__tests__/StyleSheetManager-test.js | 4 +- .../StyleSheetManager-test.js.snap | 12 ++-- .../__snapshots__/index-test.js.snap | 17 ------ .../StyleSheet/__tests__/index-test.js | 4 -- website/guides/getting-started.md | 21 +++---- .../2-apis/AppRegistry/AppRegistryScreen.js | 14 ++--- .../2-apis/StyleSheet/StyleSheetScreen.js | 12 ---- 13 files changed, 190 insertions(+), 118 deletions(-) delete mode 100644 packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/index-test.js.snap diff --git a/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap b/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap index b7e2c081..938453b9 100644 --- a/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap +++ b/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap @@ -1,6 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`apis/AppRegistry/renderApplication getApplication 1`] = ` +exports[`Additional CSS for styled app 1`] = ` +" +.rn-backgroundColor-1e4kli0{background-color:purple} +.rn-borderTopWidth-10pzpfo{border-top-width:1234px} +.rn-borderRightWidth-1y24uml{border-right-width:1234px} +.rn-borderBottomWidth-98wxn4{border-bottom-width:1234px} +.rn-borderLeftWidth-150mub4{border-left-width:1234px}" +`; + +exports[`AppRegistry/renderApplication getApplication returns "element" and "getStyleElement" 1`] = ` @@ -8,7 +17,7 @@ exports[`apis/AppRegistry/renderApplication getApplication 1`] = ` `; -exports[`apis/AppRegistry/renderApplication getApplication 2`] = ` +exports[`AppRegistry/renderApplication getApplication returns "element" and "getStyleElement" 2`] = ` "" `; + +exports[`CSS for an unstyled app 1`] = ` +"@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;} +} +@keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}} +@keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}} +.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-1pxmb3b{-ms-flex-negative:0 !important;-webkit-flex-shrink:0 !important;flex-shrink:0 !important} +.rn-flexBasis-7vfszb{-ms-flex-preferred-size:auto !important;-webkit-flex-basis:auto !important;flex-basis:auto !important} +.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-flex-13awgt0{-ms-flex:1;-webkit-flex:1;flex:1} +.rn-flexGrow-1m1wadx{-ms-flex-positive:1 !important;-webkit-flex-grow:1 !important;flex-grow:1 !important} +.rn-flexShrink-1awmn5t{-ms-flex-negative:1 !important;-webkit-flex-shrink:1 !important;flex-shrink:1 !important} +.rn-bottom-1p0dtai{bottom:0px} +.rn-left-1d2f490{left:0px} +.rn-position-u8s1d{position:absolute} +.rn-right-zchlnj{right:0px} +.rn-top-ipm5af{top:0px} +.rn-pointerEvents-12vffkv > *{pointer-events:auto} +.rn-pointerEvents-12vffkv{pointer-events:none !important}" +`; diff --git a/packages/react-native-web/src/exports/AppRegistry/__tests__/renderApplication-test.js b/packages/react-native-web/src/exports/AppRegistry/__tests__/renderApplication-test.js index 80d7c02f..6fcd8b63 100644 --- a/packages/react-native-web/src/exports/AppRegistry/__tests__/renderApplication-test.js +++ b/packages/react-native-web/src/exports/AppRegistry/__tests__/renderApplication-test.js @@ -1,19 +1,61 @@ /* eslint-env jasmine, jest */ +import ExecutionEnvironment from 'fbjs/lib/ExecutionEnvironment'; import { getApplication } from '../renderApplication'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; +import { render } from 'enzyme'; +import StyleSheet from '../../StyleSheet'; +import View from '../../View'; const RootComponent = () =>
; -describe('apis/AppRegistry/renderApplication', () => { - test('getApplication', () => { - const { element, stylesheets } = getApplication(RootComponent, {}); +describe('AppRegistry/renderApplication', () => { + describe('getApplication', () => { + const canUseDOM = ExecutionEnvironment.canUseDOM; - expect(element).toMatchSnapshot(); - stylesheets.forEach(sheet => { - const result = ReactDOMServer.renderToStaticMarkup(sheet); - expect(result).toMatchSnapshot(); + beforeEach(() => { + ExecutionEnvironment.canUseDOM = false; + }); + + afterEach(() => { + ExecutionEnvironment.canUseDOM = canUseDOM; + }); + + test('returns "element" and "getStyleElement"', () => { + const { element, getStyleElement } = getApplication(RootComponent, {}); + expect(element).toMatchSnapshot(); + expect(ReactDOMServer.renderToStaticMarkup(getStyleElement())).toMatchSnapshot(); + }); + + test('"getStyleElement" produces styles that are a function of rendering "element"', () => { + const getTextContent = getStyleElement => + getStyleElement().props.dangerouslySetInnerHTML.__html; + + // First "RootComponent" render + let app = getApplication(RootComponent, {}); + render(app.element); + const first = getTextContent(app.getStyleElement); + + // Next render is a different tree; the style sheet should be different + const styles = StyleSheet.create({ root: { borderWidth: 1234, backgroundColor: 'purple' } }); + app = getApplication(() => , {}); + render(app.element); + const second = getTextContent(app.getStyleElement); + + const diff = second.split(first)[1]; + + expect(first).toMatchSnapshot('CSS for an unstyled app'); + expect(diff).toMatchSnapshot('Additional CSS for styled app'); + expect(first).not.toEqual(second); + + // Final render is once again "RootComponent"; the style sheet should not + // be polluted by earlier rendering of a different tree + app = getApplication(RootComponent, {}); + render(app.element); + const third = getTextContent(app.getStyleElement); + + expect(first).toEqual(third); }); }); }); diff --git a/packages/react-native-web/src/exports/AppRegistry/renderApplication.js b/packages/react-native-web/src/exports/AppRegistry/renderApplication.js index b0e5b5ac..44dd33fb 100644 --- a/packages/react-native-web/src/exports/AppRegistry/renderApplication.js +++ b/packages/react-native-web/src/exports/AppRegistry/renderApplication.js @@ -13,7 +13,7 @@ import AppContainer from './AppContainer'; import invariant from 'fbjs/lib/invariant'; import hydrate from '../../modules/hydrate'; import render from '../render'; -import StyleSheet from '../StyleSheet'; +import styleResolver from '../StyleSheet/styleResolver'; import React, { type ComponentType } from 'react'; const renderFn = process.env.NODE_ENV !== 'production' ? render : hydrate; @@ -39,9 +39,10 @@ export function getApplication(RootComponent: ComponentType, initialProp ); - const stylesheets = StyleSheet.getStyleSheets().map(sheet => ( - // ensure that CSS text is not escaped - `; if (document.head) { document.head.insertAdjacentHTML('afterbegin', html); - _domStyleElement = document.getElementById(id); + domStyleElement = document.getElementById(id); } } - } - this.id = id; - this._domStyleElement = _domStyleElement; + if (domStyleElement) { + // $FlowFixMe + this._sheet = domStyleElement.sheet; + this._textContent = domStyleElement.textContent; + } + } } containsRule(rule: string): boolean { @@ -42,21 +46,15 @@ export default class WebStyleSheet { } insertRuleOnce(rule: string, position: ?number) { - // prevent duplicate rules + // Reduce chance of duplicate rules if (!this.containsRule(rule)) { this._cssRules.push(rule); - // update the native stylesheet (i.e., browser) - if (this._domStyleElement) { - // Check whether a rule was part of any prerendered styles (textContent - // doesn't include styles injected via 'insertRule') - if (this._domStyleElement.textContent.indexOf(rule) === -1) { - // $FlowFixMe - this._domStyleElement.sheet.insertRule( - rule, - position || this._domStyleElement.sheet.cssRules.length - ); - } + // 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; + this._sheet.insertRule(rule, pos); } } } diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/StyleSheetManager-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/StyleSheetManager-test.js index b50e9ac1..091a6a36 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/StyleSheetManager-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/StyleSheetManager-test.js @@ -23,9 +23,9 @@ describe('StyleSheet/StyleSheetManager', () => { }); }); - test('getStyleSheets', () => { + test('getStyleSheet', () => { styleSheetManager.injectDeclaration('--test-property', 'test-value'); - expect(styleSheetManager.getStyleSheets()).toMatchSnapshot(); + expect(styleSheetManager.getStyleSheet()).toMatchSnapshot(); }); test('injectDeclaration', () => { 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 825c6278..b346a3ed 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 @@ -2,11 +2,10 @@ exports[`StyleSheet/StyleSheetManager getClassName 1`] = `undefined`; -exports[`StyleSheet/StyleSheetManager getStyleSheets 1`] = ` -Array [ - Object { - "id": "react-native-stylesheet", - "textContent": "@media all{ +exports[`StyleSheet/StyleSheetManager getStyleSheet 1`] = ` +Object { + "id": "react-native-stylesheet", + "textContent": "@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;} @@ -15,6 +14,5 @@ input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit @keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}} @keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}} .rn---test-property-ax3bxi{--test-property:test-value}", - }, -] +} `; diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/index-test.js.snap deleted file mode 100644 index 905dfb60..00000000 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/index-test.js.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`StyleSheet getStyleSheets 1`] = ` -Array [ - Object { - "id": "react-native-stylesheet", - "textContent": "@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;} -} -@keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}} -@keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}}", - }, -] -`; diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js index 1c29b338..0f18ea44 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js @@ -48,8 +48,4 @@ describe('StyleSheet', () => { test('hairlineWidth', () => { expect(Number.isInteger(StyleSheet.hairlineWidth) === true).toBeTruthy(); }); - - test('getStyleSheets', () => { - expect(StyleSheet.getStyleSheets()).toMatchSnapshot(); - }); }); diff --git a/website/guides/getting-started.md b/website/guides/getting-started.md index dfbc16c5..6e4b6f38 100644 --- a/website/guides/getting-started.md +++ b/website/guides/getting-started.md @@ -56,7 +56,7 @@ otherwise it is not recommended. ## Server-side rendering -Server-side rendering is supported using `AppRegistry`: +Server-side rendering to HTML is supported using `AppRegistry`: ```js import App from './src/App'; @@ -67,19 +67,20 @@ import { AppRegistry } from 'react-native' AppRegistry.registerComponent('App', () => App) // prerender the app -const { element, stylesheets } = AppRegistry.getApplication('App', { initialProps }); -const initialHTML = ReactDOMServer.renderToString(element); -const initialStyles = stylesheets.map((sheet) => ReactDOMServer.renderToStaticMarkup(sheet)).join('\n'); +const { element, getStyleElement } = AppRegistry.getApplication('App', { initialProps }); +// first the element +const html = ReactDOMServer.renderToString(element); +// then the styles +const css = ReactDOMServer.renderToStaticMarkup(getStyleElement()); -// construct HTML document string +// example HTML document string const document = ` - -${initialStyles} - - -${initialHTML} + + +${css} +${html} ` ``` diff --git a/website/storybook/2-apis/AppRegistry/AppRegistryScreen.js b/website/storybook/2-apis/AppRegistry/AppRegistryScreen.js index 974ce0c7..aaa6b904 100644 --- a/website/storybook/2-apis/AppRegistry/AppRegistryScreen.js +++ b/website/storybook/2-apis/AppRegistry/AppRegistryScreen.js @@ -24,13 +24,6 @@ const AppRegistryScreen = () => (
- - @@ -105,6 +98,13 @@ const AppRegistryScreen = () => ( name="static unmountApplicationComponentAtRootTag" typeInfo="(rootTag: HTMLElement) => void" /> + +
); diff --git a/website/storybook/2-apis/StyleSheet/StyleSheetScreen.js b/website/storybook/2-apis/StyleSheet/StyleSheetScreen.js index 8787a11b..cd34d254 100644 --- a/website/storybook/2-apis/StyleSheet/StyleSheetScreen.js +++ b/website/storybook/2-apis/StyleSheet/StyleSheetScreen.js @@ -65,18 +65,6 @@ StyleSheet.flatten([styles.listItem, styles.selectedListItem]);` name="flatten" typeInfo="()" /> - - - Returns an array of stylesheets of the form {'{ id, textContent }'}. Useful - for compile-time or server-side rendering if you are not using AppRegistry. - - } - label="web" - name="getStyleSheets" - typeInfo="() => Array" - />