diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 31b1f437c9871..0b69bf72231a3 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -428,7 +428,7 @@ describe('ReactDOMFizzServer', () => {
}
function AsyncCol({className}) {
- return
{[]};
+ return ;
}
function AsyncPath({id}) {
diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
index d42e92aa1e2b4..ae7f80f045fe9 100644
--- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
@@ -7,6 +7,12 @@
* @flow
*/
+import type {ReactNodeList} from 'shared/ReactTypes';
+
+import {Children} from 'react';
+
+import {enableFilterEmptyStringAttributesDOM} from 'shared/ReactFeatureFlags';
+
import type {
Destination,
Chunk,
@@ -19,8 +25,29 @@ import {
stringToPrecomputedChunk,
} from 'react-server/src/ReactServerStreamConfig';
+import {
+ getPropertyInfo,
+ isAttributeNameSafe,
+ BOOLEAN,
+ OVERLOADED_BOOLEAN,
+ NUMERIC,
+ POSITIVE_NUMERIC,
+} from '../shared/DOMProperty';
+import {isUnitlessNumber} from '../shared/CSSProperty';
+
+import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
+import {validateProperties as validateARIAProperties} from '../shared/ReactDOMInvalidARIAHook';
+import {validateProperties as validateInputProperties} from '../shared/ReactDOMNullInputValuePropHook';
+import {validateProperties as validateUnknownProperties} from '../shared/ReactDOMUnknownPropertyHook';
+import warnValidStyle from '../shared/warnValidStyle';
+
import escapeTextForBrowser from './escapeTextForBrowser';
+import hyphenateStyleName from '../shared/hyphenateStyleName';
import invariant from 'shared/invariant';
+import sanitizeURL from '../shared/sanitizeURL';
+
+const hasOwnProperty = Object.prototype.hasOwnProperty;
+const isArray = Array.isArray;
// Per response, global state that is not contextual to the rendering subtree.
export type ResponseState = {
@@ -68,7 +95,7 @@ type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
// Lets us keep track of contextual state and pick it back up after suspending.
export type FormatContext = {
insertionMode: InsertionMode, // root/svg/html/mathml/table
- selectedValue: null | string, // the selected value(s) inside a , or null outside
+ selectedValue: null | string | Array, // the selected value(s) inside a , or null outside
};
function createFormatContext(
@@ -142,10 +169,6 @@ export function createSuspenseBoundaryID(
return {formattedID: null};
}
-function encodeHTMLIDAttribute(value: string): string {
- return escapeTextForBrowser(value);
-}
-
function encodeHTMLTextNode(text: string): string {
return escapeTextForBrowser(text);
}
@@ -202,53 +225,1088 @@ export function pushTextInstance(
target.push(stringToChunk(encodeHTMLTextNode(text)), textSeparator);
}
-const startTag1 = stringToPrecomputedChunk('<');
-const startTag2 = stringToPrecomputedChunk('>');
+const styleNameCache: Map = new Map();
+function processStyleName(styleName: string): PrecomputedChunk {
+ const chunk = styleNameCache.get(styleName);
+ if (chunk !== undefined) {
+ return chunk;
+ }
+ const result = stringToPrecomputedChunk(
+ escapeTextForBrowser(hyphenateStyleName(styleName)),
+ );
+ styleNameCache.set(styleName, result);
+ return result;
+}
+
+const styleAttributeStart = stringToPrecomputedChunk(' style="');
+const styleAssign = stringToPrecomputedChunk(':');
+const styleSeparator = stringToPrecomputedChunk(';');
+
+function pushStyle(
+ target: Array,
+ responseState: ResponseState,
+ style: Object,
+): void {
+ invariant(
+ typeof style === 'object',
+ 'The `style` prop expects a mapping from style properties to values, ' +
+ "not a string. For example, style={{marginRight: spacing + 'em'}} when " +
+ 'using JSX.',
+ );
+
+ let isFirst = true;
+ for (const styleName in style) {
+ if (!hasOwnProperty.call(style, styleName)) {
+ continue;
+ }
+ // If you provide unsafe user data here they can inject arbitrary CSS
+ // which may be problematic (I couldn't repro this):
+ // https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet
+ // http://www.thespanner.co.uk/2007/11/26/ultimate-xss-css-injection/
+ // This is not an XSS hole but instead a potential CSS injection issue
+ // which has lead to a greater discussion about how we're going to
+ // trust URLs moving forward. See #2115901
+ const styleValue = style[styleName];
+ if (
+ styleValue == null ||
+ typeof styleValue === 'boolean' ||
+ styleValue === ''
+ ) {
+ // TODO: We used to set empty string as a style with an empty value. Does that ever make sense?
+ continue;
+ }
+
+ let nameChunk;
+ let valueChunk;
+ const isCustomProperty = styleName.indexOf('--') === 0;
+ if (isCustomProperty) {
+ nameChunk = stringToChunk(escapeTextForBrowser(styleName));
+ valueChunk = stringToChunk(
+ escapeTextForBrowser(('' + styleValue).trim()),
+ );
+ } else {
+ if (__DEV__) {
+ warnValidStyle(styleName, styleValue);
+ }
+
+ nameChunk = processStyleName(styleName);
+ if (typeof styleValue === 'number') {
+ if (
+ styleValue !== 0 &&
+ !hasOwnProperty.call(isUnitlessNumber, styleName)
+ ) {
+ valueChunk = stringToChunk(styleValue + 'px'); // Presumes implicit 'px' suffix for unitless numbers
+ } else {
+ valueChunk = stringToChunk('' + styleValue);
+ }
+ } else {
+ valueChunk = stringToChunk(('' + styleValue).trim());
+ }
+ }
+ if (isFirst) {
+ isFirst = false;
+ // If it's first, we don't need any separators prefixed.
+ target.push(styleAttributeStart, nameChunk, styleAssign, valueChunk);
+ } else {
+ target.push(styleSeparator, nameChunk, styleAssign, valueChunk);
+ }
+ }
+ if (!isFirst) {
+ target.push(attributeEnd);
+ }
+}
+
+const attributeSeparator = stringToPrecomputedChunk(' ');
+const attributeAssign = stringToPrecomputedChunk('="');
+const attributeEnd = stringToPrecomputedChunk('"');
+const attributeEmptyString = stringToPrecomputedChunk('=""');
+
+function pushAttribute(
+ target: Array,
+ responseState: ResponseState,
+ name: string,
+ value: string | boolean | number | Function | Object, // not null or undefined
+): void {
+ switch (name) {
+ case 'style': {
+ pushStyle(target, responseState, value);
+ return;
+ }
+ case 'defaultValue':
+ case 'defaultChecked': // These shouldn't be set as attributes on generic HTML elements.
+ case 'innerHTML': // Must use dangerouslySetInnerHTML instead.
+ case 'suppressContentEditableWarning':
+ case 'suppressHydrationWarning':
+ // Ignored. These are built-in to React on the client.
+ return;
+ }
+ if (
+ // shouldIgnoreAttribute
+ // We have already filtered out null/undefined and reserved words.
+ name.length > 2 &&
+ (name[0] === 'o' || name[0] === 'O') &&
+ (name[1] === 'n' || name[1] === 'N')
+ ) {
+ return;
+ }
+
+ const propertyInfo = getPropertyInfo(name);
+ if (propertyInfo !== null) {
+ // shouldRemoveAttribute
+ switch (typeof value) {
+ case 'function':
+ // $FlowIssue symbol is perfectly valid here
+ case 'symbol': // eslint-disable-line
+ return;
+ case 'boolean': {
+ if (!propertyInfo.acceptsBooleans) {
+ return;
+ }
+ }
+ }
+ if (enableFilterEmptyStringAttributesDOM) {
+ if (propertyInfo.removeEmptyString && value === '') {
+ if (__DEV__) {
+ if (name === 'src') {
+ console.error(
+ 'An empty string ("") was passed to the %s attribute. ' +
+ 'This may cause the browser to download the whole page again over the network. ' +
+ 'To fix this, either do not render the element at all ' +
+ 'or pass null to %s instead of an empty string.',
+ name,
+ name,
+ );
+ } else {
+ console.error(
+ 'An empty string ("") was passed to the %s attribute. ' +
+ 'To fix this, either do not render the element at all ' +
+ 'or pass null to %s instead of an empty string.',
+ name,
+ name,
+ );
+ }
+ }
+ return;
+ }
+ }
+
+ const attributeName = propertyInfo.attributeName;
+ const attributeNameChunk = stringToChunk(attributeName); // TODO: If it's known we can cache the chunk.
+
+ switch (propertyInfo.type) {
+ case BOOLEAN:
+ if (value) {
+ target.push(
+ attributeSeparator,
+ attributeNameChunk,
+ attributeEmptyString,
+ );
+ }
+ return;
+ case OVERLOADED_BOOLEAN:
+ if (value === true) {
+ target.push(
+ attributeSeparator,
+ attributeNameChunk,
+ attributeEmptyString,
+ );
+ } else if (value === false) {
+ // Ignored
+ } else {
+ target.push(
+ attributeSeparator,
+ attributeNameChunk,
+ attributeAssign,
+ escapeTextForBrowser(value),
+ attributeEnd,
+ );
+ }
+ return;
+ case NUMERIC:
+ if (!isNaN(value)) {
+ target.push(
+ attributeSeparator,
+ attributeNameChunk,
+ attributeAssign,
+ escapeTextForBrowser(value),
+ attributeEnd,
+ );
+ }
+ break;
+ case POSITIVE_NUMERIC:
+ if (!isNaN(value) && (value: any) >= 1) {
+ target.push(
+ attributeSeparator,
+ attributeNameChunk,
+ attributeAssign,
+ escapeTextForBrowser(value),
+ attributeEnd,
+ );
+ }
+ break;
+ default:
+ if (propertyInfo.sanitizeURL) {
+ value = '' + (value: any);
+ sanitizeURL(value);
+ }
+ target.push(
+ attributeSeparator,
+ attributeNameChunk,
+ attributeAssign,
+ escapeTextForBrowser(value),
+ attributeEnd,
+ );
+ }
+ } else if (isAttributeNameSafe(name)) {
+ // shouldRemoveAttribute
+ switch (typeof value) {
+ case 'function':
+ // $FlowIssue symbol is perfectly valid here
+ case 'symbol': // eslint-disable-line
+ return;
+ case 'boolean': {
+ const prefix = name.toLowerCase().slice(0, 5);
+ if (prefix !== 'data-' && prefix !== 'aria-') {
+ return;
+ }
+ }
+ }
+ target.push(
+ attributeSeparator,
+ stringToChunk(name),
+ attributeAssign,
+ escapeTextForBrowser(value),
+ attributeEnd,
+ );
+ }
+}
+
+const endOfStartTag = stringToPrecomputedChunk('>');
+const endOfStartTagSelfClosing = stringToPrecomputedChunk('/>');
const idAttr = stringToPrecomputedChunk(' id="');
const attrEnd = stringToPrecomputedChunk('"');
-export function pushStartInstance(
+function pushID(
+ target: Array,
+ responseState: ResponseState,
+ assignID: SuspenseBoundaryID,
+ existingID: mixed,
+): void {
+ if (
+ existingID !== null &&
+ existingID !== undefined &&
+ (typeof existingID === 'string' || typeof existingID === 'object')
+ ) {
+ // We can reuse the existing ID for our purposes.
+ assignID.formattedID = stringToPrecomputedChunk(
+ escapeTextForBrowser(existingID),
+ );
+ } else {
+ const encodedID = assignAnID(responseState, assignID);
+ target.push(idAttr, encodedID, attrEnd);
+ }
+}
+
+function pushInnerHTML(
+ target: Array,
+ innerHTML,
+ children,
+) {
+ if (innerHTML != null) {
+ invariant(
+ children == null,
+ 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.',
+ );
+
+ invariant(
+ typeof innerHTML === 'object' && '__html' in innerHTML,
+ '`props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. ' +
+ 'Please visit https://reactjs.org/link/dangerously-set-inner-html ' +
+ 'for more information.',
+ );
+ const html = innerHTML.__html;
+ target.push(stringToChunk(html));
+ }
+}
+
+// TODO: Move these to ResponseState so that we warn for every request.
+// It would help debugging in stateful servers (e.g. service worker).
+let didWarnDefaultInputValue = false;
+let didWarnDefaultChecked = false;
+let didWarnDefaultSelectValue = false;
+let didWarnDefaultTextareaValue = false;
+let didWarnInvalidOptionChildren = false;
+let didWarnSelectedSetOnOption = false;
+
+function checkSelectProp(props, propName) {
+ if (__DEV__) {
+ const array = isArray(props[propName]);
+ if (props.multiple && !array) {
+ console.error(
+ 'The `%s` prop supplied to must be an array if ' +
+ '`multiple` is true.',
+ propName,
+ );
+ } else if (!props.multiple && array) {
+ console.error(
+ 'The `%s` prop supplied to must be a scalar ' +
+ 'value if `multiple` is false.',
+ propName,
+ );
+ }
+ }
+}
+
+function pushStartSelect(
target: Array,
- type: string,
props: Object,
responseState: ResponseState,
assignID: null | SuspenseBoundaryID,
-): void {
- // TODO: Figure out if it's self closing and everything else.
- if (assignID !== null) {
- let encodedID;
- if (typeof props.id === 'string') {
- // We can reuse the existing ID for our purposes.
- encodedID = assignID.formattedID = stringToPrecomputedChunk(
- encodeHTMLIDAttribute(props.id),
+): ReactNodeList {
+ if (__DEV__) {
+ checkControlledValueProps('select', props);
+
+ checkSelectProp(props, 'value');
+ checkSelectProp(props, 'defaultValue');
+
+ if (
+ props.value !== undefined &&
+ props.defaultValue !== undefined &&
+ !didWarnDefaultSelectValue
+ ) {
+ console.error(
+ '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://reactjs.org/link/controlled-components',
);
+ didWarnDefaultSelectValue = true;
+ }
+ }
+
+ target.push(startChunkForTag('select'));
+
+ let children = null;
+ let innerHTML = null;
+ for (const propKey in props) {
+ if (hasOwnProperty.call(props, propKey)) {
+ const propValue = props[propKey];
+ if (propValue == null) {
+ continue;
+ }
+ switch (propKey) {
+ case 'children':
+ children = propValue;
+ break;
+ case 'dangerouslySetInnerHTML':
+ // TODO: This doesn't really make sense for select since it can't use the controlled
+ // value in the innerHTML.
+ innerHTML = propValue;
+ break;
+ case 'defaultValue':
+ case 'value':
+ // These are set on the Context instead and applied to the nested options.
+ break;
+ default:
+ pushAttribute(target, responseState, propKey, propValue);
+ break;
+ }
+ }
+ }
+ if (assignID !== null) {
+ pushID(target, responseState, assignID, props.id);
+ }
+
+ target.push(endOfStartTag);
+ pushInnerHTML(target, innerHTML, children);
+ return children;
+}
+
+function flattenOptionChildren(children: mixed): string {
+ let content = '';
+ // Flatten children and warn if they aren't strings or numbers;
+ // invalid types are ignored.
+ Children.forEach((children: any), function(child) {
+ if (child == null) {
+ return;
+ }
+ content += (child: any);
+ if (__DEV__) {
+ if (
+ !didWarnInvalidOptionChildren &&
+ typeof child !== 'string' &&
+ typeof child !== 'number'
+ ) {
+ didWarnInvalidOptionChildren = true;
+ console.error(
+ 'Only strings and numbers are supported as children.',
+ );
+ }
+ }
+ });
+ return content;
+}
+
+const selectedMarkerAttribute = stringToPrecomputedChunk(' selected=""');
+
+function pushStartOption(
+ target: Array,
+ props: Object,
+ responseState: ResponseState,
+ formatContext: FormatContext,
+ assignID: null | SuspenseBoundaryID,
+): ReactNodeList {
+ const selectedValue = formatContext.selectedValue;
+
+ target.push(startChunkForTag('option'));
+
+ let children = null;
+ let value = null;
+ let selected = null;
+ for (const propKey in props) {
+ if (hasOwnProperty.call(props, propKey)) {
+ const propValue = props[propKey];
+ if (propValue == null) {
+ continue;
+ }
+ switch (propKey) {
+ case 'children':
+ children = propValue;
+ break;
+ case 'selected':
+ // ignore
+ selected = propValue;
+ if (__DEV__) {
+ // TODO: Remove support for `selected` in .
+ if (!didWarnSelectedSetOnOption) {
+ console.error(
+ 'Use the `defaultValue` or `value` props on instead of ' +
+ 'setting `selected` on .',
+ );
+ didWarnSelectedSetOnOption = true;
+ }
+ }
+ break;
+ case 'value':
+ value = propValue;
+ break;
+ case 'dangerouslySetInnerHTML':
+ invariant(
+ false,
+ '`dangerouslySetInnerHTML` does not work on .',
+ );
+ // eslint-disable-next-line-no-fallthrough
+ default:
+ pushAttribute(target, responseState, propKey, propValue);
+ break;
+ }
+ }
+ }
+
+ if (selectedValue !== null) {
+ let stringValue;
+ if (value !== null) {
+ stringValue = '' + value;
} else {
- encodedID = assignAnID(responseState, assignID);
+ stringValue = children = flattenOptionChildren(children);
}
- target.push(
- startTag1,
- stringToChunk(type),
- idAttr,
- encodedID,
- attrEnd,
- startTag2,
+ if (isArray(selectedValue)) {
+ // multiple
+ for (let i = 0; i < selectedValue.length; i++) {
+ const v = '' + selectedValue[i];
+ if (v === stringValue) {
+ target.push(selectedMarkerAttribute);
+ break;
+ }
+ }
+ } else if (selectedValue === stringValue) {
+ target.push(selectedMarkerAttribute);
+ }
+ } else if (selected) {
+ target.push(selectedMarkerAttribute);
+ }
+
+ if (assignID !== null) {
+ pushID(target, responseState, assignID, props.id);
+ }
+
+ target.push(endOfStartTag);
+ return children;
+}
+
+function pushInput(
+ target: Array,
+ props: Object,
+ responseState: ResponseState,
+ assignID: null | SuspenseBoundaryID,
+): ReactNodeList {
+ if (__DEV__) {
+ checkControlledValueProps('input', props);
+
+ if (
+ props.checked !== undefined &&
+ props.defaultChecked !== undefined &&
+ !didWarnDefaultChecked
+ ) {
+ console.error(
+ '%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://reactjs.org/link/controlled-components',
+ 'A component',
+ props.type,
+ );
+ didWarnDefaultChecked = true;
+ }
+ if (
+ props.value !== undefined &&
+ props.defaultValue !== undefined &&
+ !didWarnDefaultInputValue
+ ) {
+ console.error(
+ '%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://reactjs.org/link/controlled-components',
+ 'A component',
+ props.type,
+ );
+ didWarnDefaultInputValue = true;
+ }
+ }
+
+ target.push(startChunkForTag('input'));
+
+ for (const propKey in props) {
+ if (hasOwnProperty.call(props, propKey)) {
+ const propValue = props[propKey];
+ if (propValue == null) {
+ continue;
+ }
+ switch (propKey) {
+ case 'children':
+ case 'dangerouslySetInnerHTML':
+ invariant(
+ false,
+ '%s is a self-closing tag and must neither have `children` nor ' +
+ 'use `dangerouslySetInnerHTML`.',
+ 'input',
+ );
+ // eslint-disable-next-line-no-fallthrough
+ case 'defaultChecked':
+ // Previously "checked" would win but now it's enumeration order dependent.
+ // There's a warning in either case.
+ pushAttribute(target, responseState, 'checked', propValue);
+ break;
+ case 'defaultValue':
+ // Previously "value" would win but now it's enumeration order dependent.
+ // There's a warning in either case.
+ pushAttribute(target, responseState, 'value', propValue);
+ break;
+ default:
+ pushAttribute(target, responseState, propKey, propValue);
+ break;
+ }
+ }
+ }
+ if (assignID !== null) {
+ pushID(target, responseState, assignID, props.id);
+ }
+
+ target.push(endOfStartTagSelfClosing);
+ return null;
+}
+
+function pushStartTextArea(
+ target: Array,
+ props: Object,
+ responseState: ResponseState,
+ assignID: null | SuspenseBoundaryID,
+): ReactNodeList {
+ if (__DEV__) {
+ checkControlledValueProps('textarea', props);
+ if (
+ props.value !== undefined &&
+ props.defaultValue !== undefined &&
+ !didWarnDefaultTextareaValue
+ ) {
+ console.error(
+ '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://reactjs.org/link/controlled-components',
+ );
+ didWarnDefaultTextareaValue = true;
+ }
+ }
+
+ target.push(startChunkForTag('textarea'));
+
+ let value = null;
+ let children = null;
+ for (const propKey in props) {
+ if (hasOwnProperty.call(props, propKey)) {
+ const propValue = props[propKey];
+ if (propValue == null) {
+ continue;
+ }
+ switch (propKey) {
+ case 'children':
+ children = propValue;
+ break;
+ case 'value':
+ case 'defaultValue':
+ // Previously "checked" would win but now it's enumeration order dependent.
+ // There's a warning in either case.
+ value = propValue;
+ break;
+ case 'dangerouslySetInnerHTML':
+ invariant(
+ false,
+ '`dangerouslySetInnerHTML` does not make sense on