[change] AppRegistry and StyleSheet APIs to fix SSR

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
This commit is contained in:
Nicolas Gallagher
2018-01-24 18:34:29 -08:00
parent 2ad710d83a
commit 240cf7e05f
13 changed files with 190 additions and 118 deletions
@@ -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`] = `
<AppContainer
rootTag={Object {}}
>
@@ -8,7 +17,7 @@ exports[`apis/AppRegistry/renderApplication getApplication 1`] = `
</AppContainer>
`;
exports[`apis/AppRegistry/renderApplication getApplication 2`] = `
exports[`AppRegistry/renderApplication getApplication returns "element" and "getStyleElement" 2`] = `
"<style id=\\"react-native-stylesheet\\">@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;}
@@ -18,3 +27,50 @@ 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%);}}</style>"
`;
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}"
`;
@@ -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 = () => <div />;
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(() => <View style={styles.root} />, {});
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);
});
});
});
@@ -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<Object>, initialProp
<RootComponent {...initialProps} />
</AppContainer>
);
const stylesheets = StyleSheet.getStyleSheets().map(sheet => (
// ensure that CSS text is not escaped
<style dangerouslySetInnerHTML={{ __html: sheet.textContent }} id={sheet.id} key={sheet.id} />
));
return { element, stylesheets };
// Don't escape CSS text
const getStyleElement = () => {
const sheet = styleResolver.getStyleSheet();
return <style dangerouslySetInnerHTML={{ __html: sheet.textContent }} id={sheet.id} />;
};
return { element, getStyleElement };
}
@@ -11,6 +11,7 @@
* @noflow
*/
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
import createReactDOMStyle from './createReactDOMStyle';
import flattenArray from '../../modules/flattenArray';
import flattenStyle from './flattenStyle';
@@ -22,12 +23,22 @@ import StyleSheetManager from './StyleSheetManager';
const emptyObject = {};
export default class ReactNativeStyleResolver {
cache = { ltr: {}, rtl: {} };
_init() {
this.cache = { ltr: {}, rtl: {} };
this.styleSheetManager = new StyleSheetManager();
}
styleSheetManager = new StyleSheetManager();
constructor() {
this._init();
}
getStyleSheets() {
return this.styleSheetManager.getStyleSheets();
getStyleSheet() {
// reset state on the server so critical css is always the result
const sheet = this.styleSheetManager.getStyleSheet();
if (!canUseDOM) {
this._init();
}
return sheet;
}
_injectRegisteredStyle(id) {
@@ -48,27 +59,27 @@ export default class ReactNativeStyleResolver {
/**
* Resolves a React Native style object to DOM attributes
*/
resolve(reactNativeStyle) {
if (!reactNativeStyle) {
resolve(style) {
if (!style) {
return emptyObject;
}
// fast and cachable
if (typeof reactNativeStyle === 'number') {
this._injectRegisteredStyle(reactNativeStyle);
const key = createCacheKey(reactNativeStyle);
return this._resolveStyleIfNeeded(reactNativeStyle, key);
if (typeof style === 'number') {
this._injectRegisteredStyle(style);
const key = createCacheKey(style);
return this._resolveStyleIfNeeded(style, key);
}
// resolve a plain RN style object
if (!Array.isArray(reactNativeStyle)) {
return this._resolveStyleIfNeeded(reactNativeStyle);
if (!Array.isArray(style)) {
return this._resolveStyleIfNeeded(style);
}
// flatten the style array
// cache resolved props when all styles are registered
// otherwise fallback to resolving
const flatArray = flattenArray(reactNativeStyle);
const flatArray = flattenArray(style);
let isArrayOfNumbers = true;
for (let i = 0; i < flatArray.length; i++) {
const id = flatArray[i];
@@ -134,8 +145,8 @@ export default class ReactNativeStyleResolver {
/**
* Resolves a React Native style object
*/
_resolveStyle(reactNativeStyle) {
const flatStyle = flattenStyle(reactNativeStyle);
_resolveStyle(style) {
const flatStyle = flattenStyle(style);
const domStyle = createReactDOMStyle(i18nStyle(flatStyle));
const props = Object.keys(domStyle).reduce(
@@ -27,8 +27,8 @@ export default class StyleSheetManager {
byProp: {}
};
constructor(id) {
this._id = this._sheet = new WebStyleSheet(STYLE_ELEMENT_ID);
constructor() {
this._sheet = new WebStyleSheet(STYLE_ELEMENT_ID);
initialRules.forEach(rule => {
this._sheet.insertRuleOnce(rule);
});
@@ -44,15 +44,13 @@ export default class StyleSheetManager {
return cache[className] || emptyObject;
}
getStyleSheets() {
getStyleSheet() {
const { cssText } = this._sheet;
return [
{
id: STYLE_ELEMENT_ID,
textContent: cssText
}
];
return {
id: STYLE_ELEMENT_ID,
textContent: cssText
};
}
injectDeclaration(prop, value): string {
@@ -12,25 +12,29 @@ import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
export default class WebStyleSheet {
_cssRules = [];
_domStyleElement = null;
_sheet = null;
_textContent = '';
constructor(id: string) {
let _domStyleElement;
let domStyleElement;
// on the client we check for an existing style sheet before injecting
if (canUseDOM) {
_domStyleElement = document.getElementById(id);
if (!_domStyleElement) {
domStyleElement = document.getElementById(id);
if (!domStyleElement) {
const html = `<style id="${id}"></style>`;
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);
}
}
}
@@ -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', () => {
@@ -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}",
},
]
}
`;
@@ -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%);}}",
},
]
`;
@@ -48,8 +48,4 @@ describe('StyleSheet', () => {
test('hairlineWidth', () => {
expect(Number.isInteger(StyleSheet.hairlineWidth) === true).toBeTruthy();
});
test('getStyleSheets', () => {
expect(StyleSheet.getStyleSheets()).toMatchSnapshot();
});
});
+11 -10
View File
@@ -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 = `
<!DOCTYPE html>
<html>
<head>
${initialStyles}
</head>
<body>
${initialHTML}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${css}
${html}
`
```
@@ -24,13 +24,6 @@ const AppRegistryScreen = () => (
</Description>
<Section title="Methods">
<DocItem
description="Returns the given application's element and stylesheets. Use this for server-side rendering."
label="web"
name="static getApplication"
typeInfo="(appKey: string, appParameters: ?object) => { element: ReactElement; stylesheets: Array<ReactElement> }"
/>
<DocItem
description={[
<AppText>
@@ -105,6 +98,13 @@ const AppRegistryScreen = () => (
name="static unmountApplicationComponentAtRootTag"
typeInfo="(rootTag: HTMLElement) => void"
/>
<DocItem
description="Use this for server-side rendering to HTML. Returns a object of the given application's element, and a function to get styles once the element is rendered."
label="web"
name="static getApplication"
typeInfo="(appKey: string, appParameters: ?object) => { element: ReactElement; getStyleElement: () => ReactElement }"
/>
</Section>
</UIExplorer>
);
@@ -65,18 +65,6 @@ StyleSheet.flatten([styles.listItem, styles.selectedListItem]);`
name="flatten"
typeInfo="()"
/>
<DocItem
description={
<AppText>
Returns an array of stylesheets of the form <Code>{'{ id, textContent }'}</Code>. Useful
for compile-time or server-side rendering if you are not using AppRegistry.
</AppText>
}
label="web"
name="getStyleSheets"
typeInfo="() => Array"
/>
</Section>
<Section title="Properties">