Initial Stack-Free SSR Implementation (#9673)

* Server Rendering initial commit

Originally authored by spicyj, tweakes to rebase it and make it run by tomocchino.

Adding ReactDOMServerRendering and a createTagMarkup utility function which will be used in it.

* Fix build system, add shortcut scripts

* Make more ReactServerRendering-test unit tests pass

* Make ReactServerRendering-test pass with copious help from Ben

This is pretty hacky and I just inlined everything, but at least I sort of understand how it works now, and all of the tests are passing. There are also only 68 tests failing in the integration test suite.

* remove some unnecessary cruft

* Run prettier on ReactDOMServerRendering.js

* Fix more unit tests with Ben

* Add support for input and textarea by copy pasting a bunch of code from ReactDOMInput/ReactDOMTextarea

* Fix context unit tests with more copy paste :)

* progress on select

* Holy shit, 100% of ReactDOMServerIntegration tests are passing

* Checkpoint to fix some of the ReactDOMComponent-test tests

* Fix missing checkPropTypes

* Fix some unit tests in ReactDOMComponent-test

* Run prettier on everything, thanks Ben

* get rid of ssr tests that are looking for component file names

* add assertValidProps check

* rename flattenChildren to flattenOptionChildren per Ben

* Fix all the lint crap

* Move things around in the file and turn ReactDOMServerRenderer into a class

* remove changes I added to package.json

* remove separate createOpenTagMarkup file for now since everything should be copy pasted into a single file

* re-record Fiber tests

* Revert ReactDOMServer.js and the fiber tests per Sebastian

This also reverts the changes I made to ReactDOMComponent-test.js which removed the stack which is missing in the new server renderer"

* Rename files based on feedback

Moving src/renderers/dom/server/ReactDOMServerRendering.js to src/renderers/server/ReactServerRenderer.js and add src/renderers/server/ReactServer.js which makes this new codepath completely separate.

* Change throw to invariant, even though we probably need to remove this at some point

* fix prettier.. sigh
This commit is contained in:
Tom Occhino
2017-05-17 14:53:59 -07:00
committed by Sebastian Markbåge
parent e74b8ec28e
commit 78697cb941
5 changed files with 747 additions and 27 deletions
+7 -2
View File
@@ -169,7 +169,11 @@ const bundles = [
sourceMap: false,
},
entry: 'src/renderers/dom/ReactDOMServer.js',
externals: ['prop-types', 'prop-types/checkPropTypes'],
externals: [
'prop-types',
'prop-types/checkPropTypes',
'create-react-class/factory',
],
fbEntry: 'src/renderers/dom/ReactDOMServer.js',
hasteName: 'ReactDOMServerStack',
isRenderer: true,
@@ -177,11 +181,12 @@ const bundles = [
manglePropertiesOnProd: false,
name: 'react-dom/server',
paths: [
'src/isomorphic/**/*.js',
'src/renderers/dom/**/*.js',
'src/renderers/shared/**/*.js',
'src/ReactVersion.js',
'src/shared/**/*.js',
'src/addons/**/*.js',
],
},
// TODO: there is no Fiber version of ReactDOMServer.
@@ -223,29 +223,6 @@ describe('ReactComponentTreeHook', () => {
// the whole subtree automatically.
ReactDOM.unmountComponentAtNode(node);
expectWrapperTreeToEqual(null);
// Server render every pair.
// Ensure the tree is correct on every step.
pairs.forEach(([element, expectedTree]) => {
currentElement = element;
// Rendering to string should not produce any entries
// because ReactDebugTool purges it when the flush ends.
ReactDOMServer.renderToString(<Wrapper />);
expectWrapperTreeToEqual(null);
// To test it, we tell the hook to ignore next purge
// so the cleanup request by ReactDebugTool is ignored.
// This lets us make assertions on the actual tree.
ReactComponentTreeHook._preventPurging = true;
ReactDOMServer.renderToString(<Wrapper />);
ReactComponentTreeHook._preventPurging = false;
expectWrapperTreeToEqual(expectedTree);
// Purge manually since we skipped the automatic purge.
ReactComponentTreeHook.purgeUnmountedComponents();
expectWrapperTreeToEqual(null);
});
}
describeStack('mount', () => {
@@ -23,8 +23,9 @@ function getStackAddendum(debugID) {
// This can only happen on Stack
return ReactComponentTreeHook.getStackAddendumByID(debugID);
} else {
// This can only happen on Fiber
return ReactDebugCurrentFiber.getCurrentFiberStackAddendum();
// This can only happen on Fiber / Server
var stack = ReactDebugCurrentFiber.getCurrentFiberStackAddendum();
return stack != null ? stack : '';
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Copyright 2013-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule ReactServer
*/
'use strict';
var ReactDOMInjection = require('ReactDOMInjection');
var ReactDOMStackInjection = require('ReactDOMStackInjection');
var ReactServerRenderer = require('ReactServerRenderer');
var ReactVersion = require('ReactVersion');
ReactDOMInjection.inject();
ReactDOMStackInjection.inject();
var ReactServer = {
renderToString: ReactServerRenderer.renderToString,
renderToStaticMarkup: ReactServerRenderer.renderToStaticMarkup,
version: ReactVersion,
};
module.exports = ReactServer;
+709
View File
@@ -0,0 +1,709 @@
/**
* Copyright 2013-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule ReactServerRenderer
*/
'use strict';
var CSSPropertyOperations = require('CSSPropertyOperations');
var DOMPropertyOperations = require('DOMPropertyOperations');
var {registrationNameModules} = require('EventPluginRegistry');
var React = require('React');
var ReactControlledValuePropTypes = require('ReactControlledValuePropTypes');
var ReactElement = require('ReactElement');
var ReactMarkupChecksum = require('ReactMarkupChecksum');
var assertValidProps = require('assertValidProps');
var checkPropTypes = require('prop-types/checkPropTypes');
var emptyObject = require('fbjs/lib/emptyObject');
var escapeTextContentForBrowser = require('escapeTextContentForBrowser');
var invariant = require('fbjs/lib/invariant');
var omittedCloseTags = require('omittedCloseTags');
var traverseAllChildren = require('traverseAllChildren');
var warning = require('fbjs/lib/warning');
if (__DEV__) {
var {
validateProperties: validateARIAProperties,
} = require('ReactDOMInvalidARIAHook');
var {
validateProperties: validateInputPropertes,
} = require('ReactDOMNullInputValuePropHook');
var {
validateProperties: validateUnknownPropertes,
} = require('ReactDOMUnknownPropertyHook');
var validatePropertiesInDevelopment = function(type, props) {
validateARIAProperties(type, props);
validateInputPropertes(type, props);
validateUnknownPropertes(type, props);
};
}
var didWarnDefaultInputValue = false;
var didWarnDefaultChecked = false;
var didWarnDefaultSelectValue = false;
var didWarnDefaultTextareaValue = false;
var didWarnInvalidOptionChildren = false;
var valuePropNames = ['value', 'defaultValue'];
var newlineEatingTags = {
listing: true,
pre: true,
textarea: true,
};
// We accept any tag to be rendered but since this gets injected into arbitrary
// HTML, we want to make sure that it's a safe tag.
// http://www.w3.org/TR/REC-xml/#NT-Name
var VALID_TAG_REGEX = /^[a-zA-Z][a-zA-Z:_\.\-\d]*$/; // Simplified subset
var validatedTagCache = {};
function validateDangerousTag(tag) {
if (!validatedTagCache.hasOwnProperty(tag)) {
invariant(VALID_TAG_REGEX.test(tag), 'Invalid tag: %s', tag);
validatedTagCache[tag] = true;
}
}
function warnNoop(
publicInstance: ReactComponent<any, any, any>,
callerName: string,
) {
if (__DEV__) {
var constructor = publicInstance.constructor;
warning(
false,
'%s(...): Can only update a mounting component. ' +
'This usually means you called %s() outside componentWillMount() on the server. ' +
'This is a no-op.\n\nPlease check the code for the %s component.',
callerName,
callerName,
(constructor && (constructor.displayName || constructor.name)) ||
'ReactClass',
);
}
}
function shouldConstruct(Component) {
return Component.prototype && Component.prototype.isReactComponent;
}
function getNonChildrenInnerMarkup(props) {
var innerHTML = props.dangerouslySetInnerHTML;
if (innerHTML != null) {
if (innerHTML.__html != null) {
return innerHTML.__html;
}
} else {
var content = props.children;
if (typeof content === 'string' || typeof content === 'number') {
return escapeTextContentForBrowser(content);
}
}
return null;
}
function flattenOptionChildren(children) {
var content = '';
// Flatten children and warn if they aren't strings or numbers;
// invalid types are ignored.
React.Children.forEach(children, function(child) {
if (child == null) {
return;
}
if (typeof child === 'string' || typeof child === 'number') {
content += child;
} else if (!didWarnInvalidOptionChildren) {
didWarnInvalidOptionChildren = true;
warning(
false,
'Only strings and numbers are supported as <option> children.',
);
}
});
return content;
}
function maskContext(type, context) {
var contextTypes = type.contextTypes;
if (!contextTypes) {
return emptyObject;
}
var maskedContext = {};
for (var contextName in contextTypes) {
maskedContext[contextName] = context[contextName];
}
return maskedContext;
}
function checkContextTypes(typeSpecs, values, location: string) {
if (__DEV__) {
checkPropTypes(
typeSpecs,
values,
location,
'Component',
() => '', // ReactDebugCurrentFrame.getStackAddendum,
);
}
}
function processContext(type, context) {
var maskedContext = maskContext(type, context);
if (__DEV__) {
if (type.contextTypes) {
checkContextTypes(type.contextTypes, maskedContext, 'context');
}
}
return maskedContext;
}
var STYLE = 'style';
var RESERVED_PROPS = {
children: null,
dangerouslySetInnerHTML: null,
suppressContentEditableWarning: null,
};
function isCustomComponent(tagName, props) {
return tagName.indexOf('-') >= 0 || props.is != null;
}
function createOpenTagMarkup(
tagVerbatim,
tagLowercase,
props,
makeStaticMarkup,
isRootElement,
domID,
instForDebug,
) {
var ret = '<' + tagVerbatim;
for (var propKey in props) {
if (!props.hasOwnProperty(propKey)) {
continue;
}
var propValue = props[propKey];
if (propValue == null) {
continue;
}
if (!registrationNameModules.hasOwnProperty(propKey)) {
if (propKey === STYLE) {
propValue = CSSPropertyOperations.createMarkupForStyles(
propValue,
instForDebug,
);
}
var markup = null;
if (isCustomComponent(tagLowercase, props)) {
if (!RESERVED_PROPS.hasOwnProperty(propKey)) {
markup = DOMPropertyOperations.createMarkupForCustomAttribute(
propKey,
propValue,
);
}
} else {
markup = DOMPropertyOperations.createMarkupForProperty(
propKey,
propValue,
);
}
if (markup) {
ret += ' ' + markup;
}
}
}
// For static pages, no need to put React ID and checksum. Saves lots of
// bytes.
if (makeStaticMarkup) {
return ret;
}
if (isRootElement) {
ret += ' ' + DOMPropertyOperations.createMarkupForRoot();
}
ret += ' ' + DOMPropertyOperations.createMarkupForID(domID);
return ret;
}
function resolve(child, context) {
// TODO: We'll need to support Arrays (and strings) after Fiber is rolled out
invariant(!Array.isArray(child), 'Did not expect to receive an Array child');
while (
ReactElement.isValidElement(child) &&
typeof child.type === 'function'
) {
var Component = child.type;
var publicContext = processContext(Component, context);
var inst;
var queue = [];
var replace = false;
var updater = {
isMounted: function(publicInstance) {
return false;
},
enqueueForceUpdate: function(publicInstance) {
if (queue === null) {
warnNoop(publicInstance, 'forceUpdate');
return null;
}
},
enqueueReplaceState: function(publicInstance, completeState) {
replace = true;
queue = [completeState];
},
enqueueSetState: function(publicInstance, partialState) {
if (queue === null) {
warnNoop(publicInstance, 'setState');
return null;
}
queue.push(partialState);
},
};
if (shouldConstruct(Component)) {
inst = new Component(child.props, publicContext, updater);
} else {
inst = Component(child.props, publicContext, updater);
if (inst == null || inst.render == null) {
child = inst;
continue;
}
}
var initialState = inst.state;
if (initialState === undefined) {
inst.state = initialState = null;
}
if (inst.componentWillMount) {
inst.componentWillMount();
if (queue.length) {
var oldQueue = queue;
var oldReplace = replace;
queue = null;
replace = false;
if (oldReplace && oldQueue.length === 1) {
inst.state = oldQueue[0];
} else {
var nextState = oldReplace ? oldQueue[0] : inst.state;
var dontMutate = true;
for (var i = oldReplace ? 1 : 0; i < oldQueue.length; i++) {
var partial = oldQueue[i];
var partialState = typeof partial === 'function'
? partial.call(inst, nextState, child.props, publicContext)
: partial;
if (partialState) {
if (dontMutate) {
dontMutate = false;
nextState = Object.assign({}, nextState, partialState);
} else {
Object.assign(nextState, partialState);
}
}
}
inst.state = nextState;
}
} else {
queue = null;
}
}
child = inst.render();
var childContext = inst.getChildContext && inst.getChildContext();
if (childContext) {
context = Object.assign({}, context, childContext);
}
}
return {child, context};
}
class ReactDOMServerRenderer {
constructor(element, makeStaticMarkup) {
this.stack = [
{
children: [element],
childIndex: 0,
context: emptyObject,
footer: '',
},
];
this.idCounter = 1;
this.exhausted = false;
this.currentSelectValue = null;
this.makeStaticMarkup = makeStaticMarkup;
}
read(bytes) {
var out = '';
while (out.length < bytes) {
if (this.stack.length === 0) {
this.exhausted = true;
break;
}
var frame = this.stack[this.stack.length - 1];
if (frame.childIndex >= frame.children.length) {
out += frame.footer;
this.stack.pop();
if (frame.tag === 'select') {
this.currentSelectValue = null;
}
continue;
}
var child = frame.children[frame.childIndex++];
out += this.render(child, frame.context);
}
return out;
}
render(child, context) {
if (typeof child === 'string' || typeof child === 'number') {
if (this.makeStaticMarkup) {
return escapeTextContentForBrowser('' + child);
}
return (
'<!-- react-text: ' +
this.idCounter++ +
' -->' +
escapeTextContentForBrowser('' + child) +
'<!-- /react-text -->'
);
} else {
({child, context} = resolve(child, context));
if (child === null || child === false) {
return '<!-- react-empty: ' + this.idCounter++ + ' -->';
} else {
return this.renderDOM(child, context);
}
}
}
renderDOM(element, context) {
var tag = element.type.toLowerCase();
if (__DEV__) {
warning(
tag === element.type,
'<%s /> is using uppercase HTML. Always use lowercase HTML tags ' +
'in React.',
element.type,
);
}
validateDangerousTag(tag);
var props = element.props;
if (tag === 'input') {
if (__DEV__) {
ReactControlledValuePropTypes.checkPropTypes(
'input',
props,
() => '', //getCurrentFiberStackAddendum
);
if (
props.checked !== undefined &&
props.defaultChecked !== undefined &&
!didWarnDefaultChecked
) {
warning(
false,
'%s contains an input of type %s with both checked and defaultChecked props. ' +
'Input elements must be either controlled or uncontrolled ' +
'(specify either the checked prop, or the defaultChecked prop, but not ' +
'both). Decide between using a controlled or uncontrolled input ' +
'element and remove one of these props. More info: ' +
'https://fb.me/react-controlled-components',
'A component',
props.type,
);
didWarnDefaultChecked = true;
}
if (
props.value !== undefined &&
props.defaultValue !== undefined &&
!didWarnDefaultInputValue
) {
warning(
false,
'%s contains an input of type %s with both value and defaultValue props. ' +
'Input elements must be either controlled or uncontrolled ' +
'(specify either the value prop, or the defaultValue prop, but not ' +
'both). Decide between using a controlled or uncontrolled input ' +
'element and remove one of these props. More info: ' +
'https://fb.me/react-controlled-components',
'A component',
props.type,
);
didWarnDefaultInputValue = true;
}
}
props = Object.assign(
{
type: undefined,
},
props,
{
defaultChecked: undefined,
defaultValue: undefined,
value: props.value != null ? props.value : props.defaultValue,
checked: props.checked != null ? props.checked : props.defaultChecked,
},
);
} else if (tag === 'textarea') {
if (__DEV__) {
ReactControlledValuePropTypes.checkPropTypes(
'textarea',
props,
() => '', //getCurrentFiberStackAddendum
);
if (
props.value !== undefined &&
props.defaultValue !== undefined &&
!didWarnDefaultTextareaValue
) {
warning(
false,
'Textarea elements must be either controlled or uncontrolled ' +
'(specify either the value prop, or the defaultValue prop, but not ' +
'both). Decide between using a controlled or uncontrolled textarea ' +
'and remove one of these props. More info: ' +
'https://fb.me/react-controlled-components',
);
didWarnDefaultTextareaValue = true;
}
}
var initialValue = props.value;
if (initialValue == null) {
var defaultValue = props.defaultValue;
// TODO (yungsters): Remove support for children content in <textarea>.
var textareaChildren = props.children;
if (textareaChildren != null) {
if (__DEV__) {
warning(
false,
'Use the `defaultValue` or `value` props instead of setting ' +
'children on <textarea>.',
);
}
invariant(
defaultValue == null,
'If you supply `defaultValue` on a <textarea>, do not pass children.',
);
if (Array.isArray(textareaChildren)) {
invariant(
textareaChildren.length <= 1,
'<textarea> can only have at most one child.',
);
textareaChildren = textareaChildren[0];
}
defaultValue = '' + textareaChildren;
}
if (defaultValue == null) {
defaultValue = '';
}
initialValue = defaultValue;
}
props = Object.assign({}, props, {
value: undefined,
children: '' + initialValue,
});
} else if (tag === 'select') {
if (__DEV__) {
ReactControlledValuePropTypes.checkPropTypes(
'select',
props,
() => '', // getCurrentFiberStackAddendum,
);
for (var i = 0; i < valuePropNames.length; i++) {
var propName = valuePropNames[i];
if (props[propName] == null) {
continue;
}
var isArray = Array.isArray(props[propName]);
if (props.multiple && !isArray) {
warning(
false,
'The `%s` prop supplied to <select> must be an array if ' +
'`multiple` is true.%s',
propName,
'', // getDeclarationErrorAddendum(),
);
} else if (!props.multiple && isArray) {
warning(
false,
'The `%s` prop supplied to <select> must be a scalar ' +
'value if `multiple` is false.%s',
propName,
'', // getDeclarationErrorAddendum(),
);
}
}
}
if (
props.value !== undefined &&
props.defaultValue !== undefined &&
!didWarnDefaultSelectValue
) {
warning(
false,
'Select elements must be either controlled or uncontrolled ' +
'(specify either the value prop, or the defaultValue prop, but not ' +
'both). Decide between using a controlled or uncontrolled select ' +
'element and remove one of these props. More info: ' +
'https://fb.me/react-controlled-components',
);
didWarnDefaultSelectValue = true;
}
this.currentSelectValue = props.value != null
? props.value
: props.defaultValue;
props = Object.assign({}, props, {
value: undefined,
});
} else if (tag === 'option') {
var selected = null;
var selectValue = this.currentSelectValue;
var optionChildren = flattenOptionChildren(props.children);
if (selectValue != null) {
var value;
if (props.value != null) {
value = props.value + '';
} else {
value = optionChildren;
}
selected = false;
if (Array.isArray(selectValue)) {
// multiple
for (var j = 0; j < selectValue.length; j++) {
if ('' + selectValue[j] === value) {
selected = true;
break;
}
}
} else {
selected = '' + selectValue === value;
}
props = Object.assign(
{
selected: undefined,
children: undefined,
},
props,
{
selected: selected,
children: optionChildren,
},
);
}
}
if (__DEV__) {
validatePropertiesInDevelopment(tag, props);
}
assertValidProps(tag, props);
var out = createOpenTagMarkup(
element.type,
tag,
props,
this.makeStaticMarkup,
this.stack.length === 1,
this.idCounter++,
null,
);
var footer = '';
if (omittedCloseTags.hasOwnProperty(tag)) {
out += '/>';
} else {
out += '>';
footer = '</' + element.type + '>';
}
var children = [];
var innerMarkup = getNonChildrenInnerMarkup(props);
if (innerMarkup != null) {
if (newlineEatingTags[tag] && innerMarkup.charAt(0) === '\n') {
// text/html ignores the first character in these tags if it's a newline
// Prefer to break application/xml over text/html (for now) by adding
// a newline specifically to get eaten by the parser. (Alternately for
// textareas, replacing "^\n" with "\r\n" doesn't get eaten, and the first
// \r is normalized out by HTMLTextAreaElement#value.)
// See: <http://www.w3.org/TR/html-polyglot/#newlines-in-textarea-and-pre>
// See: <http://www.w3.org/TR/html5/syntax.html#element-restrictions>
// See: <http://www.w3.org/TR/html5/syntax.html#newlines>
// See: Parsing of "textarea" "listing" and "pre" elements
// from <http://www.w3.org/TR/html5/syntax.html#parsing-main-inbody>
out += '\n';
}
out += innerMarkup;
} else {
traverseAllChildren(props.children, function(ctx, child, name) {
if (child != null) {
children.push(child);
}
});
}
this.stack.push({
tag,
children,
childIndex: 0,
context: context,
footer: footer,
});
return out;
}
}
/**
* Render a ReactElement to its initial HTML. This should only be used on the
* server.
* See https://facebook.github.io/react/docs/react-dom-server.html#rendertostring
*/
function renderToString(element) {
invariant(
ReactElement.isValidElement(element),
'renderToString(): You must pass a valid ReactElement.',
);
var renderer = new ReactDOMServerRenderer(element, false);
var markup = renderer.read(Infinity);
markup = ReactMarkupChecksum.addChecksumToMarkup(markup);
return markup;
}
/**
* Similar to renderToString, except this doesn't create extra DOM attributes
* such as data-react-id that React uses internally.
* See https://facebook.github.io/react/docs/react-dom-server.html#rendertostaticmarkup
*/
function renderToStaticMarkup(element) {
invariant(
ReactElement.isValidElement(element),
'renderToStaticMarkup(): You must pass a valid ReactElement.',
);
var renderer = new ReactDOMServerRenderer(element, true);
var markup = renderer.read(Infinity);
return markup;
}
var ReactServerRenderer = {
renderToString: renderToString,
renderToStaticMarkup: renderToStaticMarkup,
};
module.exports = ReactServerRenderer;