From 816a718e0af2be0b977aefc68214673894e384cb Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 29 Mar 2021 22:38:39 -0400 Subject: [PATCH 1/9] Implement DOM format config structure --- .../src/__tests__/ReactDOMFizzServer-test.js | 2 +- .../src/server/ReactDOMServerFormatConfig.js | 781 +++++++++++++++++- .../server/ReactNativeServerFormatConfig.js | 5 +- .../src/ReactNoopServer.js | 5 +- packages/react-server/src/ReactFizzServer.js | 9 +- 5 files changed, 760 insertions(+), 42 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 31b1f437c987..0b69bf72231a 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 d42e92aa1e2b..fb224bf7811b 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -7,6 +7,10 @@ * @flow */ +import type {ReactNodeList} from 'shared/ReactTypes'; + +import {enableFilterEmptyStringAttributesDOM} from 'shared/ReactFeatureFlags'; + import type { Destination, Chunk, @@ -19,8 +23,24 @@ import { stringToPrecomputedChunk, } from 'react-server/src/ReactServerStreamConfig'; +import { + getPropertyInfo, + isAttributeNameSafe, + BOOLEAN, + OVERLOADED_BOOLEAN, + NUMERIC, + POSITIVE_NUMERIC, +} from '../shared/DOMProperty'; + +import {validateProperties as validateARIAProperties} from '../shared/ReactDOMInvalidARIAHook'; +import {validateProperties as validateInputProperties} from '../shared/ReactDOMNullInputValuePropHook'; +import {validateProperties as validateUnknownProperties} from '../shared/ReactDOMUnknownPropertyHook'; + import escapeTextForBrowser from './escapeTextForBrowser'; import invariant from 'shared/invariant'; +import sanitizeURL from '../shared/sanitizeURL'; + +const hasOwnProperty = Object.prototype.hasOwnProperty; // Per response, global state that is not contextual to the rendering subtree. export type ResponseState = { @@ -142,10 +162,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 +218,720 @@ export function pushTextInstance( target.push(stringToChunk(encodeHTMLTextNode(text)), textSeparator); } -const startTag1 = stringToPrecomputedChunk('<'); -const startTag2 = stringToPrecomputedChunk('>'); +function pushStyle( + target: Array, + responseState: ResponseState, + style: mixed, +): 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.', + ); + // TODO +} + +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; + target.push(stringToChunk(html)); + } +} + +function pushStartSelect( + target: Array, + props: Object, + responseState: ResponseState, + assignID: null | SuspenseBoundaryID, +): ReactNodeList { + 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; + 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 pushStartOption( + target: Array, + props: Object, + responseState: ResponseState, + assignID: null | SuspenseBoundaryID, +): ReactNodeList { + target.push(startChunkForTag('option')); + + 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 'dangerouslySetInnerHTML': + invariant( + false, + '`dangerouslySetInnerHTML` does not work on