@samitouri / QOS-React-2 / commits / b9e4c10e99

[Fizz] Implement all the DOM attributes and special cases (#21153)

* Implement DOM format config structure * Styles * Input warnings * Textarea special cases * Select special cases * Option special cases We read the currently selected value from the FormatContext. * Warning for non-lower case HTML We don't change to lower case at runtime anymore but keep the warning. * Pre tags innerHTML needs to be prefixed This is because if you do the equivalent on the client using innerHTML, this is the effect you'd get. * Extract errors

Sebastian Markbåge committed Mar 31, 2021 at 20:39 UTC b9e4c10e99940ceaff77e1f6f4cb0c747e91acff
6 files changed +1142 -44
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+1 -1
@@ -428,7 +428,7 @@ describe('ReactDOMFizzServer', () => {
428 }
429
430 function AsyncCol({className}) {
431 - return <col className={readText(className)}>{[]}</col>;
431 + return <col className={readText(className)} />;
432 }
433
434 function AsyncPath({id}) {
packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+1120 -38
@@ -7,6 +7,12 @@
7 * @flow
8 */
9
10 +import type {ReactNodeList} from 'shared/ReactTypes';
11 +
12 +import {Children} from 'react';
13 +
14 +import {enableFilterEmptyStringAttributesDOM} from 'shared/ReactFeatureFlags';
15 +
16 import type {
17 Destination,
18 Chunk,
@@ -19,8 +25,29 @@ import {
25 stringToPrecomputedChunk,
26 } from 'react-server/src/ReactServerStreamConfig';
27
28 +import {
29 + getPropertyInfo,
30 + isAttributeNameSafe,
31 + BOOLEAN,
32 + OVERLOADED_BOOLEAN,
33 + NUMERIC,
34 + POSITIVE_NUMERIC,
35 +} from '../shared/DOMProperty';
36 +import {isUnitlessNumber} from '../shared/CSSProperty';
37 +
38 +import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
39 +import {validateProperties as validateARIAProperties} from '../shared/ReactDOMInvalidARIAHook';
40 +import {validateProperties as validateInputProperties} from '../shared/ReactDOMNullInputValuePropHook';
41 +import {validateProperties as validateUnknownProperties} from '../shared/ReactDOMUnknownPropertyHook';
42 +import warnValidStyle from '../shared/warnValidStyle';
43 +
44 import escapeTextForBrowser from './escapeTextForBrowser';
45 +import hyphenateStyleName from '../shared/hyphenateStyleName';
46 import invariant from 'shared/invariant';
47 +import sanitizeURL from '../shared/sanitizeURL';
48 +
49 +const hasOwnProperty = Object.prototype.hasOwnProperty;
50 +const isArray = Array.isArray;
51
52 // Per response, global state that is not contextual to the rendering subtree.
53 export type ResponseState = {
@@ -68,7 +95,7 @@ type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
95 // Lets us keep track of contextual state and pick it back up after suspending.
96 export type FormatContext = {
97 insertionMode: InsertionMode, // root/svg/html/mathml/table
71 - selectedValue: null | string, // the selected value(s) inside a <select>, or null outside <select>
98 + selectedValue: null | string | Array<string>, // the selected value(s) inside a <select>, or null outside <select>
99 };
100
101 function createFormatContext(
@@ -142,10 +169,6 @@ export function createSuspenseBoundaryID(
169 return {formattedID: null};
170 }
171
145 -function encodeHTMLIDAttribute(value: string): string {
146 - return escapeTextForBrowser(value);
147 -}
148 -
172 function encodeHTMLTextNode(text: string): string {
173 return escapeTextForBrowser(text);
174 }
@@ -202,53 +225,1088 @@ export function pushTextInstance(
225 target.push(stringToChunk(encodeHTMLTextNode(text)), textSeparator);
226 }
227
205 -const startTag1 = stringToPrecomputedChunk('<');
206 -const startTag2 = stringToPrecomputedChunk('>');
228 +const styleNameCache: Map<string, PrecomputedChunk> = new Map();
229 +function processStyleName(styleName: string): PrecomputedChunk {
230 + const chunk = styleNameCache.get(styleName);
231 + if (chunk !== undefined) {
232 + return chunk;
233 + }
234 + const result = stringToPrecomputedChunk(
235 + escapeTextForBrowser(hyphenateStyleName(styleName)),
236 + );
237 + styleNameCache.set(styleName, result);
238 + return result;
239 +}
240 +
241 +const styleAttributeStart = stringToPrecomputedChunk(' style="');
242 +const styleAssign = stringToPrecomputedChunk(':');
243 +const styleSeparator = stringToPrecomputedChunk(';');
244 +
245 +function pushStyle(
246 + target: Array<Chunk | PrecomputedChunk>,
247 + responseState: ResponseState,
248 + style: Object,
249 +): void {
250 + invariant(
251 + typeof style === 'object',
252 + 'The `style` prop expects a mapping from style properties to values, ' +
253 + "not a string. For example, style={{marginRight: spacing + 'em'}} when " +
254 + 'using JSX.',
255 + );
256 +
257 + let isFirst = true;
258 + for (const styleName in style) {
259 + if (!hasOwnProperty.call(style, styleName)) {
260 + continue;
261 + }
262 + // If you provide unsafe user data here they can inject arbitrary CSS
263 + // which may be problematic (I couldn't repro this):
264 + // https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet
265 + // http://www.thespanner.co.uk/2007/11/26/ultimate-xss-css-injection/
266 + // This is not an XSS hole but instead a potential CSS injection issue
267 + // which has lead to a greater discussion about how we're going to
268 + // trust URLs moving forward. See #2115901
269 + const styleValue = style[styleName];
270 + if (
271 + styleValue == null ||
272 + typeof styleValue === 'boolean' ||
273 + styleValue === ''
274 + ) {
275 + // TODO: We used to set empty string as a style with an empty value. Does that ever make sense?
276 + continue;
277 + }
278 +
279 + let nameChunk;
280 + let valueChunk;
281 + const isCustomProperty = styleName.indexOf('--') === 0;
282 + if (isCustomProperty) {
283 + nameChunk = stringToChunk(escapeTextForBrowser(styleName));
284 + valueChunk = stringToChunk(
285 + escapeTextForBrowser(('' + styleValue).trim()),
286 + );
287 + } else {
288 + if (__DEV__) {
289 + warnValidStyle(styleName, styleValue);
290 + }
291 +
292 + nameChunk = processStyleName(styleName);
293 + if (typeof styleValue === 'number') {
294 + if (
295 + styleValue !== 0 &&
296 + !hasOwnProperty.call(isUnitlessNumber, styleName)
297 + ) {
298 + valueChunk = stringToChunk(styleValue + 'px'); // Presumes implicit 'px' suffix for unitless numbers
299 + } else {
300 + valueChunk = stringToChunk('' + styleValue);
301 + }
302 + } else {
303 + valueChunk = stringToChunk(('' + styleValue).trim());
304 + }
305 + }
306 + if (isFirst) {
307 + isFirst = false;
308 + // If it's first, we don't need any separators prefixed.
309 + target.push(styleAttributeStart, nameChunk, styleAssign, valueChunk);
310 + } else {
311 + target.push(styleSeparator, nameChunk, styleAssign, valueChunk);
312 + }
313 + }
314 + if (!isFirst) {
315 + target.push(attributeEnd);
316 + }
317 +}
318 +
319 +const attributeSeparator = stringToPrecomputedChunk(' ');
320 +const attributeAssign = stringToPrecomputedChunk('="');
321 +const attributeEnd = stringToPrecomputedChunk('"');
322 +const attributeEmptyString = stringToPrecomputedChunk('=""');
323 +
324 +function pushAttribute(
325 + target: Array<Chunk | PrecomputedChunk>,
326 + responseState: ResponseState,
327 + name: string,
328 + value: string | boolean | number | Function | Object, // not null or undefined
329 +): void {
330 + switch (name) {
331 + case 'style': {
332 + pushStyle(target, responseState, value);
333 + return;
334 + }
335 + case 'defaultValue':
336 + case 'defaultChecked': // These shouldn't be set as attributes on generic HTML elements.
337 + case 'innerHTML': // Must use dangerouslySetInnerHTML instead.
338 + case 'suppressContentEditableWarning':
339 + case 'suppressHydrationWarning':
340 + // Ignored. These are built-in to React on the client.
341 + return;
342 + }
343 + if (
344 + // shouldIgnoreAttribute
345 + // We have already filtered out null/undefined and reserved words.
346 + name.length > 2 &&
347 + (name[0] === 'o' || name[0] === 'O') &&
348 + (name[1] === 'n' || name[1] === 'N')
349 + ) {
350 + return;
351 + }
352 +
353 + const propertyInfo = getPropertyInfo(name);
354 + if (propertyInfo !== null) {
355 + // shouldRemoveAttribute
356 + switch (typeof value) {
357 + case 'function':
358 + // $FlowIssue symbol is perfectly valid here
359 + case 'symbol': // eslint-disable-line
360 + return;
361 + case 'boolean': {
362 + if (!propertyInfo.acceptsBooleans) {
363 + return;
364 + }
365 + }
366 + }
367 + if (enableFilterEmptyStringAttributesDOM) {
368 + if (propertyInfo.removeEmptyString && value === '') {
369 + if (__DEV__) {
370 + if (name === 'src') {
371 + console.error(
372 + 'An empty string ("") was passed to the %s attribute. ' +
373 + 'This may cause the browser to download the whole page again over the network. ' +
374 + 'To fix this, either do not render the element at all ' +
375 + 'or pass null to %s instead of an empty string.',
376 + name,
377 + name,
378 + );
379 + } else {
380 + console.error(
381 + 'An empty string ("") was passed to the %s attribute. ' +
382 + 'To fix this, either do not render the element at all ' +
383 + 'or pass null to %s instead of an empty string.',
384 + name,
385 + name,
386 + );
387 + }
388 + }
389 + return;
390 + }
391 + }
392 +
393 + const attributeName = propertyInfo.attributeName;
394 + const attributeNameChunk = stringToChunk(attributeName); // TODO: If it's known we can cache the chunk.
395 +
396 + switch (propertyInfo.type) {
397 + case BOOLEAN:
398 + if (value) {
399 + target.push(
400 + attributeSeparator,
401 + attributeNameChunk,
402 + attributeEmptyString,
403 + );
404 + }
405 + return;
406 + case OVERLOADED_BOOLEAN:
407 + if (value === true) {
408 + target.push(
409 + attributeSeparator,
410 + attributeNameChunk,
411 + attributeEmptyString,
412 + );
413 + } else if (value === false) {
414 + // Ignored
415 + } else {
416 + target.push(
417 + attributeSeparator,
418 + attributeNameChunk,
419 + attributeAssign,
420 + escapeTextForBrowser(value),
421 + attributeEnd,
422 + );
423 + }
424 + return;
425 + case NUMERIC:
426 + if (!isNaN(value)) {
427 + target.push(
428 + attributeSeparator,
429 + attributeNameChunk,
430 + attributeAssign,
431 + escapeTextForBrowser(value),
432 + attributeEnd,
433 + );
434 + }
435 + break;
436 + case POSITIVE_NUMERIC:
437 + if (!isNaN(value) && (value: any) >= 1) {
438 + target.push(
439 + attributeSeparator,
440 + attributeNameChunk,
441 + attributeAssign,
442 + escapeTextForBrowser(value),
443 + attributeEnd,
444 + );
445 + }
446 + break;
447 + default:
448 + if (propertyInfo.sanitizeURL) {
449 + value = '' + (value: any);
450 + sanitizeURL(value);
451 + }
452 + target.push(
453 + attributeSeparator,
454 + attributeNameChunk,
455 + attributeAssign,
456 + escapeTextForBrowser(value),
457 + attributeEnd,
458 + );
459 + }
460 + } else if (isAttributeNameSafe(name)) {
461 + // shouldRemoveAttribute
462 + switch (typeof value) {
463 + case 'function':
464 + // $FlowIssue symbol is perfectly valid here
465 + case 'symbol': // eslint-disable-line
466 + return;
467 + case 'boolean': {
468 + const prefix = name.toLowerCase().slice(0, 5);
469 + if (prefix !== 'data-' && prefix !== 'aria-') {
470 + return;
471 + }
472 + }
473 + }
474 + target.push(
475 + attributeSeparator,
476 + stringToChunk(name),
477 + attributeAssign,
478 + escapeTextForBrowser(value),
479 + attributeEnd,
480 + );
481 + }
482 +}
483 +
484 +const endOfStartTag = stringToPrecomputedChunk('>');
485 +const endOfStartTagSelfClosing = stringToPrecomputedChunk('/>');
486
487 const idAttr = stringToPrecomputedChunk(' id="');
488 const attrEnd = stringToPrecomputedChunk('"');
489
211 -export function pushStartInstance(
490 +function pushID(
491 + target: Array<Chunk | PrecomputedChunk>,
492 + responseState: ResponseState,
493 + assignID: SuspenseBoundaryID,
494 + existingID: mixed,
495 +): void {
496 + if (
497 + existingID !== null &&
498 + existingID !== undefined &&
499 + (typeof existingID === 'string' || typeof existingID === 'object')
500 + ) {
501 + // We can reuse the existing ID for our purposes.
502 + assignID.formattedID = stringToPrecomputedChunk(
503 + escapeTextForBrowser(existingID),
504 + );
505 + } else {
506 + const encodedID = assignAnID(responseState, assignID);
507 + target.push(idAttr, encodedID, attrEnd);
508 + }
509 +}
510 +
511 +function pushInnerHTML(
512 + target: Array<Chunk | PrecomputedChunk>,
513 + innerHTML,
514 + children,
515 +) {
516 + if (innerHTML != null) {
517 + invariant(
518 + children == null,
519 + 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.',
520 + );
521 +
522 + invariant(
523 + typeof innerHTML === 'object' && '__html' in innerHTML,
524 + '`props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. ' +
525 + 'Please visit https://reactjs.org/link/dangerously-set-inner-html ' +
526 + 'for more information.',
527 + );
528 + const html = innerHTML.__html;
529 + target.push(stringToChunk(html));
530 + }
531 +}
532 +
533 +// TODO: Move these to ResponseState so that we warn for every request.
534 +// It would help debugging in stateful servers (e.g. service worker).
535 +let didWarnDefaultInputValue = false;
536 +let didWarnDefaultChecked = false;
537 +let didWarnDefaultSelectValue = false;
538 +let didWarnDefaultTextareaValue = false;
539 +let didWarnInvalidOptionChildren = false;
540 +let didWarnSelectedSetOnOption = false;
541 +
542 +function checkSelectProp(props, propName) {
543 + if (__DEV__) {
544 + const array = isArray(props[propName]);
545 + if (props.multiple && !array) {
546 + console.error(
547 + 'The `%s` prop supplied to <select> must be an array if ' +
548 + '`multiple` is true.',
549 + propName,
550 + );
551 + } else if (!props.multiple && array) {
552 + console.error(
553 + 'The `%s` prop supplied to <select> must be a scalar ' +
554 + 'value if `multiple` is false.',
555 + propName,
556 + );
557 + }
558 + }
559 +}
560 +
561 +function pushStartSelect(
562 target: Array<Chunk | PrecomputedChunk>,
213 - type: string,
563 props: Object,
564 responseState: ResponseState,
565 assignID: null | SuspenseBoundaryID,
217 -): void {
218 - // TODO: Figure out if it's self closing and everything else.
219 - if (assignID !== null) {
220 - let encodedID;
221 - if (typeof props.id === 'string') {
222 - // We can reuse the existing ID for our purposes.
223 - encodedID = assignID.formattedID = stringToPrecomputedChunk(
224 - encodeHTMLIDAttribute(props.id),
566 +): ReactNodeList {
567 + if (__DEV__) {
568 + checkControlledValueProps('select', props);
569 +
570 + checkSelectProp(props, 'value');
571 + checkSelectProp(props, 'defaultValue');
572 +
573 + if (
574 + props.value !== undefined &&
575 + props.defaultValue !== undefined &&
576 + !didWarnDefaultSelectValue
577 + ) {
578 + console.error(
579 + 'Select elements must be either controlled or uncontrolled ' +
580 + '(specify either the value prop, or the defaultValue prop, but not ' +
581 + 'both). Decide between using a controlled or uncontrolled select ' +
582 + 'element and remove one of these props. More info: ' +
583 + 'https://reactjs.org/link/controlled-components',
584 );
585 + didWarnDefaultSelectValue = true;
586 + }
587 + }
588 +
589 + target.push(startChunkForTag('select'));
590 +
591 + let children = null;
592 + let innerHTML = null;
593 + for (const propKey in props) {
594 + if (hasOwnProperty.call(props, propKey)) {
595 + const propValue = props[propKey];
596 + if (propValue == null) {
597 + continue;
598 + }
599 + switch (propKey) {
600 + case 'children':
601 + children = propValue;
602 + break;
603 + case 'dangerouslySetInnerHTML':
604 + // TODO: This doesn't really make sense for select since it can't use the controlled
605 + // value in the innerHTML.
606 + innerHTML = propValue;
607 + break;
608 + case 'defaultValue':
609 + case 'value':
610 + // These are set on the Context instead and applied to the nested options.
611 + break;
612 + default:
613 + pushAttribute(target, responseState, propKey, propValue);
614 + break;
615 + }
616 + }
617 + }
618 + if (assignID !== null) {
619 + pushID(target, responseState, assignID, props.id);
620 + }
621 +
622 + target.push(endOfStartTag);
623 + pushInnerHTML(target, innerHTML, children);
624 + return children;
625 +}
626 +
627 +function flattenOptionChildren(children: mixed): string {
628 + let content = '';
629 + // Flatten children and warn if they aren't strings or numbers;
630 + // invalid types are ignored.
631 + Children.forEach((children: any), function(child) {
632 + if (child == null) {
633 + return;
634 + }
635 + content += (child: any);
636 + if (__DEV__) {
637 + if (
638 + !didWarnInvalidOptionChildren &&
639 + typeof child !== 'string' &&
640 + typeof child !== 'number'
641 + ) {
642 + didWarnInvalidOptionChildren = true;
643 + console.error(
644 + 'Only strings and numbers are supported as <option> children.',
645 + );
646 + }
647 + }
648 + });
649 + return content;
650 +}
651 +
652 +const selectedMarkerAttribute = stringToPrecomputedChunk(' selected=""');
653 +
654 +function pushStartOption(
655 + target: Array<Chunk | PrecomputedChunk>,
656 + props: Object,
657 + responseState: ResponseState,
658 + formatContext: FormatContext,
659 + assignID: null | SuspenseBoundaryID,
660 +): ReactNodeList {
661 + const selectedValue = formatContext.selectedValue;
662 +
663 + target.push(startChunkForTag('option'));
664 +
665 + let children = null;
666 + let value = null;
667 + let selected = null;
668 + for (const propKey in props) {
669 + if (hasOwnProperty.call(props, propKey)) {
670 + const propValue = props[propKey];
671 + if (propValue == null) {
672 + continue;
673 + }
674 + switch (propKey) {
675 + case 'children':
676 + children = propValue;
677 + break;
678 + case 'selected':
679 + // ignore
680 + selected = propValue;
681 + if (__DEV__) {
682 + // TODO: Remove support for `selected` in <option>.
683 + if (!didWarnSelectedSetOnOption) {
684 + console.error(
685 + 'Use the `defaultValue` or `value` props on <select> instead of ' +
686 + 'setting `selected` on <option>.',
687 + );
688 + didWarnSelectedSetOnOption = true;
689 + }
690 + }
691 + break;
692 + case 'value':
693 + value = propValue;
694 + break;
695 + case 'dangerouslySetInnerHTML':
696 + invariant(
697 + false,
698 + '`dangerouslySetInnerHTML` does not work on <option>.',
699 + );
700 + // eslint-disable-next-line-no-fallthrough
701 + default:
702 + pushAttribute(target, responseState, propKey, propValue);
703 + break;
704 + }
705 + }
706 + }
707 +
708 + if (selectedValue !== null) {
709 + let stringValue;
710 + if (value !== null) {
711 + stringValue = '' + value;
712 } else {
227 - encodedID = assignAnID(responseState, assignID);
713 + stringValue = children = flattenOptionChildren(children);
714 }
229 - target.push(
230 - startTag1,
231 - stringToChunk(type),
232 - idAttr,
233 - encodedID,
234 - attrEnd,
235 - startTag2,
715 + if (isArray(selectedValue)) {
716 + // multiple
717 + for (let i = 0; i < selectedValue.length; i++) {
718 + const v = '' + selectedValue[i];
719 + if (v === stringValue) {
720 + target.push(selectedMarkerAttribute);
721 + break;
722 + }
723 + }
724 + } else if (selectedValue === stringValue) {
725 + target.push(selectedMarkerAttribute);
726 + }
727 + } else if (selected) {
728 + target.push(selectedMarkerAttribute);
729 + }
730 +
731 + if (assignID !== null) {
732 + pushID(target, responseState, assignID, props.id);
733 + }
734 +
735 + target.push(endOfStartTag);
736 + return children;
737 +}
738 +
739 +function pushInput(
740 + target: Array<Chunk | PrecomputedChunk>,
741 + props: Object,
742 + responseState: ResponseState,
743 + assignID: null | SuspenseBoundaryID,
744 +): ReactNodeList {
745 + if (__DEV__) {
746 + checkControlledValueProps('input', props);
747 +
748 + if (
749 + props.checked !== undefined &&
750 + props.defaultChecked !== undefined &&
751 + !didWarnDefaultChecked
752 + ) {
753 + console.error(
754 + '%s contains an input of type %s with both checked and defaultChecked props. ' +
755 + 'Input elements must be either controlled or uncontrolled ' +
756 + '(specify either the checked prop, or the defaultChecked prop, but not ' +
757 + 'both). Decide between using a controlled or uncontrolled input ' +
758 + 'element and remove one of these props. More info: ' +
759 + 'https://reactjs.org/link/controlled-components',
760 + 'A component',
761 + props.type,
762 + );
763 + didWarnDefaultChecked = true;
764 + }
765 + if (
766 + props.value !== undefined &&
767 + props.defaultValue !== undefined &&
768 + !didWarnDefaultInputValue
769 + ) {
770 + console.error(
771 + '%s contains an input of type %s with both value and defaultValue props. ' +
772 + 'Input elements must be either controlled or uncontrolled ' +
773 + '(specify either the value prop, or the defaultValue prop, but not ' +
774 + 'both). Decide between using a controlled or uncontrolled input ' +
775 + 'element and remove one of these props. More info: ' +
776 + 'https://reactjs.org/link/controlled-components',
777 + 'A component',
778 + props.type,
779 + );
780 + didWarnDefaultInputValue = true;
781 + }
782 + }
783 +
784 + target.push(startChunkForTag('input'));
785 +
786 + for (const propKey in props) {
787 + if (hasOwnProperty.call(props, propKey)) {
788 + const propValue = props[propKey];
789 + if (propValue == null) {
790 + continue;
791 + }
792 + switch (propKey) {
793 + case 'children':
794 + case 'dangerouslySetInnerHTML':
795 + invariant(
796 + false,
797 + '%s is a self-closing tag and must neither have `children` nor ' +
798 + 'use `dangerouslySetInnerHTML`.',
799 + 'input',
800 + );
801 + // eslint-disable-next-line-no-fallthrough
802 + case 'defaultChecked':
803 + // Previously "checked" would win but now it's enumeration order dependent.
804 + // There's a warning in either case.
805 + pushAttribute(target, responseState, 'checked', propValue);
806 + break;
807 + case 'defaultValue':
808 + // Previously "value" would win but now it's enumeration order dependent.
809 + // There's a warning in either case.
810 + pushAttribute(target, responseState, 'value', propValue);
811 + break;
812 + default:
813 + pushAttribute(target, responseState, propKey, propValue);
814 + break;
815 + }
816 + }
817 + }
818 + if (assignID !== null) {
819 + pushID(target, responseState, assignID, props.id);
820 + }
821 +
822 + target.push(endOfStartTagSelfClosing);
823 + return null;
824 +}
825 +
826 +function pushStartTextArea(
827 + target: Array<Chunk | PrecomputedChunk>,
828 + props: Object,
829 + responseState: ResponseState,
830 + assignID: null | SuspenseBoundaryID,
831 +): ReactNodeList {
832 + if (__DEV__) {
833 + checkControlledValueProps('textarea', props);
834 + if (
835 + props.value !== undefined &&
836 + props.defaultValue !== undefined &&
837 + !didWarnDefaultTextareaValue
838 + ) {
839 + console.error(
840 + 'Textarea elements must be either controlled or uncontrolled ' +
841 + '(specify either the value prop, or the defaultValue prop, but not ' +
842 + 'both). Decide between using a controlled or uncontrolled textarea ' +
843 + 'and remove one of these props. More info: ' +
844 + 'https://reactjs.org/link/controlled-components',
845 + );
846 + didWarnDefaultTextareaValue = true;
847 + }
848 + }
849 +
850 + target.push(startChunkForTag('textarea'));
851 +
852 + let value = null;
853 + let children = null;
854 + for (const propKey in props) {
855 + if (hasOwnProperty.call(props, propKey)) {
856 + const propValue = props[propKey];
857 + if (propValue == null) {
858 + continue;
859 + }
860 + switch (propKey) {
861 + case 'children':
862 + children = propValue;
863 + break;
864 + case 'value':
865 + case 'defaultValue':
866 + // Previously "checked" would win but now it's enumeration order dependent.
867 + // There's a warning in either case.
868 + value = propValue;
869 + break;
870 + case 'dangerouslySetInnerHTML':
871 + invariant(
872 + false,
873 + '`dangerouslySetInnerHTML` does not make sense on <textarea>.',
874 + );
875 + // eslint-disable-next-line-no-fallthrough
876 + default:
877 + pushAttribute(target, responseState, propKey, propValue);
878 + break;
879 + }
880 + }
881 + }
882 + if (assignID !== null) {
883 + pushID(target, responseState, assignID, props.id);
884 + }
885 +
886 + target.push(endOfStartTag);
887 +
888 + // TODO (yungsters): Remove support for children content in <textarea>.
889 + if (children != null) {
890 + if (__DEV__) {
891 + console.error(
892 + 'Use the `defaultValue` or `value` props instead of setting ' +
893 + 'children on <textarea>.',
894 + );
895 + }
896 + invariant(
897 + value == null,
898 + 'If you supply `defaultValue` on a <textarea>, do not pass children.',
899 );
237 - } else {
238 - target.push(startTag1, stringToChunk(type));
239 - if (props.className) {
240 - target.push(
241 - stringToChunk(
242 - ' class="' + encodeHTMLIDAttribute(props.className) + '"',
243 - ),
900 + if (isArray(children)) {
901 + invariant(
902 + children.length <= 1,
903 + '<textarea> can only have at most one child.',
904 );
905 + value = '' + children[0];
906 }
246 - if (props.id) {
247 - target.push(
248 - stringToChunk(' id="' + encodeHTMLIDAttribute(props.id) + '"'),
907 + value = '' + children;
908 + }
909 +
910 + if (typeof value === 'string' && value[0] === '\n') {
911 + // text/html ignores the first character in these tags if it's a newline
912 + // Prefer to break application/xml over text/html (for now) by adding
913 + // a newline specifically to get eaten by the parser. (Alternately for
914 + // textareas, replacing "^\n" with "\r\n" doesn't get eaten, and the first
915 + // \r is normalized out by HTMLTextAreaElement#value.)
916 + // See: <http://www.w3.org/TR/html-polyglot/#newlines-in-textarea-and-pre>
917 + // See: <http://www.w3.org/TR/html5/syntax.html#element-restrictions>
918 + // See: <http://www.w3.org/TR/html5/syntax.html#newlines>
919 + // See: Parsing of "textarea" "listing" and "pre" elements
920 + // from <http://www.w3.org/TR/html5/syntax.html#parsing-main-inbody>
921 + target.push(leadingNewline);
922 + }
923 +
924 + return value;
925 +}
926 +
927 +function pushSelfClosing(
928 + target: Array<Chunk | PrecomputedChunk>,
929 + props: Object,
930 + tag: string,
931 + responseState: ResponseState,
932 + assignID: null | SuspenseBoundaryID,
933 +): ReactNodeList {
934 + target.push(startChunkForTag(tag));
935 +
936 + for (const propKey in props) {
937 + if (hasOwnProperty.call(props, propKey)) {
938 + const propValue = props[propKey];
939 + if (propValue == null) {
940 + continue;
941 + }
942 + switch (propKey) {
943 + case 'children':
944 + case 'dangerouslySetInnerHTML':
945 + invariant(
946 + false,
947 + '%s is a self-closing tag and must neither have `children` nor ' +
948 + 'use `dangerouslySetInnerHTML`.',
949 + tag,
950 + );
951 + // eslint-disable-next-line-no-fallthrough
952 + default:
953 + pushAttribute(target, responseState, propKey, propValue);
954 + break;
955 + }
956 + }
957 + }
958 + if (assignID !== null) {
959 + pushID(target, responseState, assignID, props.id);
960 + }
961 +
962 + target.push(endOfStartTagSelfClosing);
963 + return null;
964 +}
965 +
966 +function pushStartMenuItem(
967 + target: Array<Chunk | PrecomputedChunk>,
968 + props: Object,
969 + responseState: ResponseState,
970 + assignID: null | SuspenseBoundaryID,
971 +): ReactNodeList {
972 + target.push(startChunkForTag('menuitem'));
973 +
974 + for (const propKey in props) {
975 + if (hasOwnProperty.call(props, propKey)) {
976 + const propValue = props[propKey];
977 + if (propValue == null) {
978 + continue;
979 + }
980 + switch (propKey) {
981 + case 'children':
982 + case 'dangerouslySetInnerHTML':
983 + invariant(
984 + false,
985 + 'menuitems cannot have `children` nor `dangerouslySetInnerHTML`.',
986 + );
987 + // eslint-disable-next-line-no-fallthrough
988 + default:
989 + pushAttribute(target, responseState, propKey, propValue);
990 + break;
991 + }
992 + }
993 + }
994 + if (assignID !== null) {
995 + pushID(target, responseState, assignID, props.id);
996 + }
997 +
998 + target.push(endOfStartTag);
999 + return null;
1000 +}
1001 +
1002 +function pushStartGenericElement(
1003 + target: Array<Chunk | PrecomputedChunk>,
1004 + props: Object,
1005 + tag: string,
1006 + responseState: ResponseState,
1007 + assignID: null | SuspenseBoundaryID,
1008 +): ReactNodeList {
1009 + target.push(startChunkForTag(tag));
1010 +
1011 + let children = null;
1012 + let innerHTML = null;
1013 + for (const propKey in props) {
1014 + if (hasOwnProperty.call(props, propKey)) {
1015 + const propValue = props[propKey];
1016 + if (propValue == null) {
1017 + continue;
1018 + }
1019 + switch (propKey) {
1020 + case 'children':
1021 + children = propValue;
1022 + break;
1023 + case 'dangerouslySetInnerHTML':
1024 + innerHTML = propValue;
1025 + break;
1026 + default:
1027 + pushAttribute(target, responseState, propKey, propValue);
1028 + break;
1029 + }
1030 + }
1031 + }
1032 + if (assignID !== null) {
1033 + pushID(target, responseState, assignID, props.id);
1034 + }
1035 +
1036 + target.push(endOfStartTag);
1037 + pushInnerHTML(target, innerHTML, children);
1038 + return children;
1039 +}
1040 +
1041 +function pushStartCustomElement(
1042 + target: Array<Chunk | PrecomputedChunk>,
1043 + props: Object,
1044 + tag: string,
1045 + responseState: ResponseState,
1046 + assignID: null | SuspenseBoundaryID,
1047 +): ReactNodeList {
1048 + target.push(startChunkForTag(tag));
1049 +
1050 + let children = null;
1051 + let innerHTML = null;
1052 + for (const propKey in props) {
1053 + if (hasOwnProperty.call(props, propKey)) {
1054 + const propValue = props[propKey];
1055 + if (propValue == null) {
1056 + continue;
1057 + }
1058 + switch (propKey) {
1059 + case 'children':
1060 + children = propValue;
1061 + break;
1062 + case 'dangerouslySetInnerHTML':
1063 + innerHTML = propValue;
1064 + break;
1065 + case 'style':
1066 + pushStyle(target, responseState, propValue);
1067 + break;
1068 + case 'suppressContentEditableWarning':
1069 + case 'suppressHydrationWarning':
1070 + // Ignored. These are built-in to React on the client.
1071 + break;
1072 + default:
1073 + if (isAttributeNameSafe(propKey)) {
1074 + target.push(
1075 + attributeSeparator,
1076 + stringToChunk(propKey),
1077 + attributeAssign,
1078 + escapeTextForBrowser(propValue),
1079 + attributeEnd,
1080 + );
1081 + }
1082 + break;
1083 + }
1084 + }
1085 + }
1086 + if (assignID !== null) {
1087 + pushID(target, responseState, assignID, props.id);
1088 + }
1089 +
1090 + target.push(endOfStartTag);
1091 + pushInnerHTML(target, innerHTML, children);
1092 + return children;
1093 +}
1094 +
1095 +const leadingNewline = stringToPrecomputedChunk('\n');
1096 +
1097 +function pushStartPreformattedElement(
1098 + target: Array<Chunk | PrecomputedChunk>,
1099 + props: Object,
1100 + tag: string,
1101 + responseState: ResponseState,
1102 + assignID: null | SuspenseBoundaryID,
1103 +): ReactNodeList {
1104 + target.push(startChunkForTag(tag));
1105 +
1106 + let children = null;
1107 + let innerHTML = null;
1108 + for (const propKey in props) {
1109 + if (hasOwnProperty.call(props, propKey)) {
1110 + const propValue = props[propKey];
1111 + if (propValue == null) {
1112 + continue;
1113 + }
1114 + switch (propKey) {
1115 + case 'children':
1116 + children = propValue;
1117 + break;
1118 + case 'dangerouslySetInnerHTML':
1119 + innerHTML = propValue;
1120 + break;
1121 + default:
1122 + pushAttribute(target, responseState, propKey, propValue);
1123 + break;
1124 + }
1125 + }
1126 + }
1127 + if (assignID !== null) {
1128 + pushID(target, responseState, assignID, props.id);
1129 + }
1130 +
1131 + target.push(endOfStartTag);
1132 +
1133 + // text/html ignores the first character in these tags if it's a newline
1134 + // Prefer to break application/xml over text/html (for now) by adding
1135 + // a newline specifically to get eaten by the parser. (Alternately for
1136 + // textareas, replacing "^\n" with "\r\n" doesn't get eaten, and the first
1137 + // \r is normalized out by HTMLTextAreaElement#value.)
1138 + // See: <http://www.w3.org/TR/html-polyglot/#newlines-in-textarea-and-pre>
1139 + // See: <http://www.w3.org/TR/html5/syntax.html#element-restrictions>
1140 + // See: <http://www.w3.org/TR/html5/syntax.html#newlines>
1141 + // See: Parsing of "textarea" "listing" and "pre" elements
1142 + // from <http://www.w3.org/TR/html5/syntax.html#parsing-main-inbody>
1143 + // TODO: This doesn't deal with the case where the child is an array
1144 + // or component that returns a string.
1145 + if (innerHTML != null) {
1146 + invariant(
1147 + children == null,
1148 + 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.',
1149 + );
1150 +
1151 + invariant(
1152 + typeof innerHTML === 'object' && '__html' in innerHTML,
1153 + '`props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. ' +
1154 + 'Please visit https://reactjs.org/link/dangerously-set-inner-html ' +
1155 + 'for more information.',
1156 + );
1157 + const html = innerHTML.__html;
1158 + if (typeof html === 'string' && html[0] === '\n') {
1159 + target.push(leadingNewline);
1160 + }
1161 + target.push(stringToChunk(html));
1162 + }
1163 + if (typeof children === 'string' && children[0] === '\n') {
1164 + target.push(leadingNewline);
1165 + }
1166 + return children;
1167 +}
1168 +
1169 +// We accept any tag to be rendered but since this gets injected into arbitrary
1170 +// HTML, we want to make sure that it's a safe tag.
1171 +// http://www.w3.org/TR/REC-xml/#NT-Name
1172 +const VALID_TAG_REGEX = /^[a-zA-Z][a-zA-Z:_\.\-\d]*$/; // Simplified subset
1173 +const validatedTagCache = new Map();
1174 +function startChunkForTag(tag: string): PrecomputedChunk {
1175 + let tagStartChunk = validatedTagCache.get(tag);
1176 + if (tagStartChunk === undefined) {
1177 + invariant(VALID_TAG_REGEX.test(tag), 'Invalid tag: %s', tag);
1178 + tagStartChunk = stringToPrecomputedChunk('<' + tag);
1179 + validatedTagCache.set(tag, tagStartChunk);
1180 + }
1181 + return tagStartChunk;
1182 +}
1183 +
1184 +export function pushStartInstance(
1185 + target: Array<Chunk | PrecomputedChunk>,
1186 + type: string,
1187 + props: Object,
1188 + responseState: ResponseState,
1189 + formatContext: FormatContext,
1190 + assignID: null | SuspenseBoundaryID,
1191 +): ReactNodeList {
1192 + if (__DEV__) {
1193 + validateARIAProperties(type, props);
1194 + validateInputProperties(type, props);
1195 + validateUnknownProperties(type, props, null);
1196 +
1197 + if (
1198 + !props.suppressContentEditableWarning &&
1199 + props.contentEditable &&
1200 + props.children != null
1201 + ) {
1202 + console.error(
1203 + 'A component is `contentEditable` and contains `children` managed by ' +
1204 + 'React. It is now your responsibility to guarantee that none of ' +
1205 + 'those nodes are unexpectedly modified or duplicated. This is ' +
1206 + 'probably not intentional.',
1207 + );
1208 + }
1209 +
1210 + if (
1211 + formatContext.insertionMode !== SVG_MODE &&
1212 + formatContext.insertionMode !== MATHML_MODE
1213 + ) {
1214 + if (type.toLowerCase() !== type) {
1215 + console.error(
1216 + '<%s /> is using incorrect casing. ' +
1217 + 'Use PascalCase for React components, ' +
1218 + 'or lowercase for HTML elements.',
1219 + type,
1220 + );
1221 + }
1222 + }
1223 + }
1224 +
1225 + switch (type) {
1226 + // Special tags
1227 + case 'select':
1228 + return pushStartSelect(target, props, responseState, assignID);
1229 + case 'option':
1230 + return pushStartOption(
1231 + target,
1232 + props,
1233 + responseState,
1234 + formatContext,
1235 + assignID,
1236 + );
1237 + case 'textarea':
1238 + return pushStartTextArea(target, props, responseState, assignID);
1239 + case 'input':
1240 + return pushInput(target, props, responseState, assignID);
1241 + case 'menuitem':
1242 + return pushStartMenuItem(target, props, responseState, assignID);
1243 + // Newline eating tags
1244 + case 'listing':
1245 + case 'pre': {
1246 + return pushStartPreformattedElement(
1247 + target,
1248 + props,
1249 + type,
1250 + responseState,
1251 + assignID,
1252 + );
1253 + }
1254 + // Omitted close tags
1255 + case 'area':
1256 + case 'base':
1257 + case 'br':
1258 + case 'col':
1259 + case 'embed':
1260 + case 'hr':
1261 + case 'img':
1262 + case 'keygen':
1263 + case 'link':
1264 + case 'meta':
1265 + case 'param':
1266 + case 'source':
1267 + case 'track':
1268 + case 'wbr': {
1269 + return pushSelfClosing(target, props, type, responseState, assignID);
1270 + }
1271 + // These are reserved SVG and MathML elements, that are never custom elements.
1272 + // https://w3c.github.io/webcomponents/spec/custom/#custom-elements-core-concepts
1273 + case 'annotation-xml':
1274 + case 'color-profile':
1275 + case 'font-face':
1276 + case 'font-face-src':
1277 + case 'font-face-uri':
1278 + case 'font-face-format':
1279 + case 'font-face-name':
1280 + case 'missing-glyph': {
1281 + return pushStartGenericElement(
1282 + target,
1283 + props,
1284 + type,
1285 + responseState,
1286 + assignID,
1287 );
1288 }
251 - target.push(startTag2);
1289 + default: {
1290 + if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
1291 + // Generic element
1292 + return pushStartGenericElement(
1293 + target,
1294 + props,
1295 + type,
1296 + responseState,
1297 + assignID,
1298 + );
1299 + } else {
1300 + // Custom element
1301 + return pushStartCustomElement(
1302 + target,
1303 + props,
1304 + type,
1305 + responseState,
1306 + assignID,
1307 + );
1308 + }
1309 + }
1310 }
1311 }
1312
@@ -260,8 +1318,32 @@ export function pushEndInstance(
1318 type: string,
1319 props: Object,
1320 ): void {
263 - // TODO: Figure out if it was self closing.
264 - target.push(endTag1, stringToChunk(type), endTag2);
1321 + switch (type) {
1322 + // Omitted close tags
1323 + // TODO: Instead of repeating this switch we could try to pass a flag from above.
1324 + // That would require returning a tuple. Which might be ok if it gets inlined.
1325 + case 'area':
1326 + case 'base':
1327 + case 'br':
1328 + case 'col':
1329 + case 'embed':
1330 + case 'hr':
1331 + case 'img':
1332 + case 'input':
1333 + case 'keygen':
1334 + case 'link':
1335 + case 'meta':
1336 + case 'param':
1337 + case 'source':
1338 + case 'track':
1339 + case 'wbr': {
1340 + // No close tag needed.
1341 + break;
1342 + }
1343 + default: {
1344 + target.push(endTag1, stringToChunk(type), endTag2);
1345 + }
1346 + }
1347 }
1348
1349 // Structural Nodes
packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+5 -1
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +import type {ReactNodeList} from 'shared/ReactTypes';
11 +
12 import type {
13 Destination,
14 Chunk,
@@ -134,14 +136,16 @@ export function pushStartInstance(
136 type: string,
137 props: Object,
138 responseState: ResponseState,
139 + formatContext: FormatContext,
140 assignID: null | SuspenseBoundaryID,
138 -): void {
141 +): ReactNodeList {
142 target.push(
143 INSTANCE,
144 stringToChunk(type),
145 END, // Null terminated type string
146 // TODO: props
147 );
148 + return props.children;
149 }
150
151 export function pushEndInstance(
packages/react-noop-renderer/src/ReactNoopServer.js
+4 -1
@@ -14,6 +14,8 @@
14 * environment.
15 */
16
17 +import type {ReactNodeList} from 'shared/ReactTypes';
18 +
19 import ReactFizzServer from 'react-server';
20
21 type Instance = {|
@@ -97,7 +99,7 @@ const ReactNoopServer = ReactFizzServer({
99 target: Array<Uint8Array>,
100 type: string,
101 props: Object,
100 - ): void {
102 + ): ReactNodeList {
103 const instance: Instance = {
104 type: type,
105 children: [],
@@ -105,6 +107,7 @@ const ReactNoopServer = ReactFizzServer({
107 hidden: false,
108 };
109 target.push(Buffer.from(JSON.stringify(instance), 'utf8'));
110 + return props.children;
111 },
112
113 pushEndInstance(
packages/react-server/src/ReactFizzServer.js
+8 -2
@@ -302,6 +302,11 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void {
302 return;
303 }
304
305 + if (node === null) {
306 + pushEmpty(task.blockedSegment.chunks, request.responseState, task.assignID);
307 + return;
308 + }
309 +
310 if (
311 typeof node !== 'object' ||
312 !node ||
@@ -347,18 +352,19 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void {
352 }
353 } else if (typeof type === 'string') {
354 const segment = task.blockedSegment;
350 - pushStartInstance(
355 + const children = pushStartInstance(
356 segment.chunks,
357 type,
358 props,
359 request.responseState,
360 + segment.formatContext,
361 task.assignID,
362 );
363 // We must have assigned it already above so we don't need this anymore.
364 task.assignID = null;
365 const prevContext = segment.formatContext;
366 segment.formatContext = getChildFormatContext(prevContext, type, props);
361 - renderNode(request, task, props.children);
367 + renderNode(request, task, children);
368 // We expect that errors will fatal the whole task and that we don't need
369 // the correct context. Therefore this is not in a finally.
370 segment.formatContext = prevContext;
scripts/error-codes/codes.json
+4 -1
@@ -385,5 +385,8 @@
385 "394": "startTransition cannot be called during server rendering.",
386 "395": "An ID must have been assigned before we can complete the boundary.",
387 "396": "More boundaries or placeholders than we expected to ever emit.",
388 - "397": "Unknown insertion mode. This is a bug in React."
388 + "397": "Unknown insertion mode. This is a bug in React.",
389 + "398": "`dangerouslySetInnerHTML` does not work on <option>.",
390 + "399": "%s is a self-closing tag and must neither have `children` nor use `dangerouslySetInnerHTML`.",
391 + "400": "menuitems cannot have `children` nor `dangerouslySetInnerHTML`."
392 }