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 + selectedValue: null | string | Array, // the selected value(s) inside a }; 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 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