[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
}