/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow */ import type {ReactNodeList} from 'shared/ReactTypes'; import {Children} from 'react'; import {enableFilterEmptyStringAttributesDOM} from 'shared/ReactFeatureFlags'; import type { Destination, Chunk, PrecomputedChunk, } from 'react-server/src/ReactServerStreamConfig'; import { writeChunk, stringToChunk, 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 hasOwnProperty from 'shared/hasOwnProperty'; import sanitizeURL from '../shared/sanitizeURL'; import isArray from 'shared/isArray'; // Used to distinguish these contexts from ones used in other renderers. // E.g. this can be used to distinguish legacy renderers from this modern one. export const isPrimaryRenderer = true; // Per response, global state that is not contextual to the rendering subtree. export type ResponseState = { placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, opaqueIdentifierPrefix: string, nextSuspenseID: number, nextOpaqueID: number, sentCompleteSegmentFunction: boolean, sentCompleteBoundaryFunction: boolean, sentClientRenderFunction: boolean, // We allow the legacy renderer to extend this object. ... }; // Allows us to keep track of what we've already written so we can refer back to it. export function createResponseState( identifierPrefix: string | void, ): ResponseState { const idPrefix = identifierPrefix === undefined ? '' : identifierPrefix; return { placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'), segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), boundaryPrefix: idPrefix + 'B:', opaqueIdentifierPrefix: idPrefix + 'R:', nextSuspenseID: 0, nextOpaqueID: 0, sentCompleteSegmentFunction: false, sentCompleteBoundaryFunction: false, sentClientRenderFunction: false, }; } // Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion // modes. We only include the variants as they matter for the sake of our purposes. // We don't actually provide the namespace therefore we use constants instead of the string. const ROOT_HTML_MODE = 0; // Used for the root most element tag. export const HTML_MODE = 1; const SVG_MODE = 2; const MATHML_MODE = 3; const HTML_TABLE_MODE = 4; const HTML_TABLE_BODY_MODE = 5; const HTML_TABLE_ROW_MODE = 6; const HTML_COLGROUP_MODE = 7; // We have a greater than HTML_TABLE_MODE check elsewhere. If you add more cases here, make sure it // still makes sense 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 | Array, // the selected value(s) inside a }; function createFormatContext( insertionMode: InsertionMode, selectedValue: null | string, ): FormatContext { return { insertionMode, selectedValue, }; } export function createRootFormatContext(namespaceURI?: string): FormatContext { const insertionMode = namespaceURI === 'http://www.w3.org/2000/svg' ? SVG_MODE : namespaceURI === 'http://www.w3.org/1998/Math/MathML' ? MATHML_MODE : ROOT_HTML_MODE; return createFormatContext(insertionMode, null); } export function getChildFormatContext( parentContext: FormatContext, type: string, props: Object, ): FormatContext { switch (type) { case 'select': return createFormatContext( HTML_MODE, props.value != null ? props.value : props.defaultValue, ); case 'svg': return createFormatContext(SVG_MODE, null); case 'math': return createFormatContext(MATHML_MODE, null); case 'foreignObject': return createFormatContext(HTML_MODE, null); // Table parents are special in that their children can only be created at all if they're // wrapped in a table parent. So we need to encode that we're entering this mode. case 'table': return createFormatContext(HTML_TABLE_MODE, null); case 'thead': case 'tbody': case 'tfoot': return createFormatContext(HTML_TABLE_BODY_MODE, null); case 'colgroup': return createFormatContext(HTML_COLGROUP_MODE, null); case 'tr': return createFormatContext(HTML_TABLE_ROW_MODE, null); } if (parentContext.insertionMode >= HTML_TABLE_MODE) { // Whatever tag this was, it wasn't a table parent or other special parent, so we must have // entered plain HTML again. return createFormatContext(HTML_MODE, null); } if (parentContext.insertionMode === ROOT_HTML_MODE) { // We've emitted the root and is now in plain HTML mode. return createFormatContext(HTML_MODE, null); } return parentContext; } // This object is used to lazily reuse the ID of the first generated node, or assign one. // We can't assign an ID up front because the node we're attaching it to might already // have one. So we need to lazily use that if it's available. export type SuspenseBoundaryID = { formattedID: null | PrecomputedChunk, }; export function createSuspenseBoundaryID( responseState: ResponseState, ): SuspenseBoundaryID { return {formattedID: null}; } export type OpaqueIDType = string; export function makeServerID( responseState: null | ResponseState, ): OpaqueIDType { invariant( responseState !== null, 'Invalid hook call. Hooks can only be called inside of the body of a function component.', ); // TODO: This is not deterministic since it's created during render. return ( responseState.opaqueIdentifierPrefix + (responseState.nextOpaqueID++).toString(36) ); } function encodeHTMLTextNode(text: string): string { return escapeTextForBrowser(text); } function assignAnID( responseState: ResponseState, id: SuspenseBoundaryID, ): PrecomputedChunk { // TODO: This approach doesn't yield deterministic results since this is assigned during render. const generatedID = responseState.nextSuspenseID++; return (id.formattedID = stringToPrecomputedChunk( responseState.boundaryPrefix + generatedID.toString(16), )); } const dummyNode1 = stringToPrecomputedChunk(''); function pushDummyNodeWithID( target: Array, responseState: ResponseState, assignID: SuspenseBoundaryID, ): void { const id = assignAnID(responseState, assignID); target.push(dummyNode1, id, dummyNode2); } export function pushEmpty( target: Array, responseState: ResponseState, assignID: null | SuspenseBoundaryID, ): void { if (assignID !== null) { pushDummyNodeWithID(target, responseState, assignID); } } const textSeparator = stringToPrecomputedChunk(''); export function pushTextInstance( target: Array, text: string, responseState: ResponseState, assignID: null | SuspenseBoundaryID, ): void { if (assignID !== null) { pushDummyNodeWithID(target, responseState, assignID); } if (text === '') { // Empty text doesn't have a DOM node representation and the hydration is aware of this. return; } // TODO: Avoid adding a text separator in common cases. target.push(stringToChunk(encodeHTMLTextNode(text)), textSeparator); } 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( escapeTextForBrowser(('' + 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('"'); 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; if (html !== null && html !== undefined) { 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 didWarnInvalidOptionInnerHTML = false; let didWarnSelectedSetOnOption = false; function checkSelectProp(props, propName) { if (__DEV__) { const value = props[propName]; if (value != null) { const array = isArray(value); 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, props: Object, responseState: ResponseState, assignID: null | SuspenseBoundaryID, ): 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( 'Cannot infer the option value of complex children. ' + 'Pass a `value` prop or use a plain string as children to