main
js 694 lines 17.3 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
11 import type {HydrationDiffNode} from 'react-reconciler/src/ReactFiberHydrationDiffs';
12
13 import {
14 current,
15 runWithFiberInDEV,
16 } from 'react-reconciler/src/ReactCurrentFiber';
17 import {
18 HostComponent,
19 HostHoistable,
20 HostSingleton,
21 HostText,
22 } from 'react-reconciler/src/ReactWorkTags';
23
24 import {describeDiff} from 'react-reconciler/src/ReactFiberHydrationDiffs';
25
26 function describeAncestors(
27 ancestor: Fiber,
28 child: Fiber,
29 props: null | {children: null},
30 ): string {
31 let fiber: null | Fiber = child;
32 let node: null | HydrationDiffNode = null;
33 let distanceFromLeaf = 0;
34 while (fiber) {
35 if (fiber === ancestor) {
36 distanceFromLeaf = 0;
37 }
38 node = {
39 fiber: fiber,
40 children: node !== null ? [node] : [],
41 serverProps:
42 fiber === child ? props : fiber === ancestor ? null : undefined,
43 serverTail: [],
44 distanceFromLeaf: distanceFromLeaf,
45 };
46 distanceFromLeaf++;
47 fiber = fiber.return;
48 }
49 if (node !== null) {
50 // Describe the node using the hydration diff logic.
51 // Replace + with - to mark ancestor and child. It's kind of arbitrary.
52 return describeDiff(node).replaceAll(/^[+-]/gm, '>');
53 }
54 return '';
55 }
56
57 type Info = {tag: string};
58 export type AncestorInfoDev = {
59 current: ?Info,
60
61 formTag: ?Info,
62 aTagInScope: ?Info,
63 buttonTagInScope: ?Info,
64 nobrTagInScope: ?Info,
65 pTagInButtonScope: ?Info,
66
67 listItemTagAutoclosing: ?Info,
68 dlItemTagAutoclosing: ?Info,
69
70 // <head> or <body>
71 containerTagInScope: ?Info,
72 implicitRootScope: boolean,
73 };
74
75 // This validation code was written based on the HTML5 parsing spec:
76 // https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope
77 //
78 // Note: this does not catch all invalid nesting, nor does it try to (as it's
79 // not clear what practical benefit doing so provides); instead, we warn only
80 // for cases where the parser will give a parse tree differing from what React
81 // intended. For example, <b><div></div></b> is invalid but we don't warn
82 // because it still parses correctly; we do warn for other cases like nested
83 // <p> tags where the beginning of the second element implicitly closes the
84 // first, causing a confusing mess.
85
86 // https://html.spec.whatwg.org/multipage/syntax.html#special
87 const specialTags = [
88 'address',
89 'applet',
90 'area',
91 'article',
92 'aside',
93 'base',
94 'basefont',
95 'bgsound',
96 'blockquote',
97 'body',
98 'br',
99 'button',
100 'caption',
101 'center',
102 'col',
103 'colgroup',
104 'dd',
105 'details',
106 'dir',
107 'div',
108 'dl',
109 'dt',
110 'embed',
111 'fieldset',
112 'figcaption',
113 'figure',
114 'footer',
115 'form',
116 'frame',
117 'frameset',
118 'h1',
119 'h2',
120 'h3',
121 'h4',
122 'h5',
123 'h6',
124 'head',
125 'header',
126 'hgroup',
127 'hr',
128 'html',
129 'iframe',
130 'img',
131 'input',
132 'isindex',
133 'li',
134 'link',
135 'listing',
136 'main',
137 'marquee',
138 'menu',
139 'menuitem',
140 'meta',
141 'nav',
142 'noembed',
143 'noframes',
144 'noscript',
145 'object',
146 'ol',
147 'p',
148 'param',
149 'plaintext',
150 'pre',
151 'script',
152 'section',
153 'select',
154 'source',
155 'style',
156 'summary',
157 'table',
158 'tbody',
159 'td',
160 'template',
161 'textarea',
162 'tfoot',
163 'th',
164 'thead',
165 'title',
166 'tr',
167 'track',
168 'ul',
169 'wbr',
170 'xmp',
171 ];
172
173 // https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope
174 const inScopeTags = [
175 'applet',
176 'caption',
177 'html',
178 'table',
179 'td',
180 'th',
181 'marquee',
182 'object',
183 'template',
184
185 // https://html.spec.whatwg.org/multipage/syntax.html#html-integration-point
186 // TODO: Distinguish by namespace here -- for <title>, including it here
187 // errs on the side of fewer warnings
188 'foreignObject',
189 'desc',
190 'title',
191 ];
192
193 // https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-button-scope
194 const buttonScopeTags = __DEV__ ? inScopeTags.concat(['button']) : [];
195
196 // https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags
197 const impliedEndTags = [
198 'dd',
199 'dt',
200 'li',
201 'option',
202 'optgroup',
203 'p',
204 'rp',
205 'rt',
206 ];
207
208 const emptyAncestorInfoDev: AncestorInfoDev = {
209 current: null,
210
211 formTag: null,
212 aTagInScope: null,
213 buttonTagInScope: null,
214 nobrTagInScope: null,
215 pTagInButtonScope: null,
216
217 listItemTagAutoclosing: null,
218 dlItemTagAutoclosing: null,
219
220 containerTagInScope: null,
221 implicitRootScope: false,
222 };
223
224 function updatedAncestorInfoDev(
225 oldInfo: null | AncestorInfoDev,
226 tag: string,
227 ): AncestorInfoDev {
228 if (__DEV__) {
229 const ancestorInfo = {...(oldInfo || emptyAncestorInfoDev)};
230 const info = {tag};
231
232 if (inScopeTags.indexOf(tag) !== -1) {
233 ancestorInfo.aTagInScope = null;
234 ancestorInfo.buttonTagInScope = null;
235 ancestorInfo.nobrTagInScope = null;
236 }
237 if (buttonScopeTags.indexOf(tag) !== -1) {
238 ancestorInfo.pTagInButtonScope = null;
239 }
240
241 if (
242 specialTags.indexOf(tag) !== -1 &&
243 tag !== 'address' &&
244 tag !== 'div' &&
245 tag !== 'p'
246 ) {
247 // See rules for 'li', 'dd', 'dt' start tags in
248 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
249 ancestorInfo.listItemTagAutoclosing = null;
250 ancestorInfo.dlItemTagAutoclosing = null;
251 }
252
253 ancestorInfo.current = info;
254
255 if (tag === 'form') {
256 ancestorInfo.formTag = info;
257 }
258 if (tag === 'a') {
259 ancestorInfo.aTagInScope = info;
260 }
261 if (tag === 'button') {
262 ancestorInfo.buttonTagInScope = info;
263 }
264 if (tag === 'nobr') {
265 ancestorInfo.nobrTagInScope = info;
266 }
267 if (tag === 'p') {
268 ancestorInfo.pTagInButtonScope = info;
269 }
270 if (tag === 'li') {
271 ancestorInfo.listItemTagAutoclosing = info;
272 }
273 if (tag === 'dd' || tag === 'dt') {
274 ancestorInfo.dlItemTagAutoclosing = info;
275 }
276 if (tag === '#document' || tag === 'html') {
277 ancestorInfo.containerTagInScope = null;
278 } else if (!ancestorInfo.containerTagInScope) {
279 ancestorInfo.containerTagInScope = info;
280 }
281
282 if (
283 oldInfo === null &&
284 (tag === '#document' || tag === 'html' || tag === 'body')
285 ) {
286 // While <head> is also a singleton we don't want to support semantics where
287 // you can escape the head by rendering a body singleton so we treat it like a normal scope
288 ancestorInfo.implicitRootScope = true;
289 } else if (ancestorInfo.implicitRootScope === true) {
290 ancestorInfo.implicitRootScope = false;
291 }
292
293 return ancestorInfo;
294 } else {
295 return null as any;
296 }
297 }
298
299 /**
300 * Returns whether
301 */
302 function isTagValidWithParent(
303 tag: string,
304 parentTag: ?string,
305 implicitRootScope: boolean,
306 ): boolean {
307 // First, let's check if we're in an unusual parsing mode...
308 switch (parentTag) {
309 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
310 case 'select':
311 return (
312 tag === 'hr' ||
313 tag === 'option' ||
314 tag === 'optgroup' ||
315 tag === 'script' ||
316 tag === 'template' ||
317 tag === '#text'
318 );
319 case 'optgroup':
320 return tag === 'option' || tag === '#text';
321 // Strictly speaking, seeing an <option> doesn't mean we're in a <select>
322 // but
323 case 'option':
324 return tag === '#text';
325 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd
326 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incaption
327 // No special behavior since these rules fall back to "in body" mode for
328 // all except special table nodes which cause bad parsing behavior anyway.
329
330 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intr
331 case 'tr':
332 return (
333 tag === 'th' ||
334 tag === 'td' ||
335 tag === 'style' ||
336 tag === 'script' ||
337 tag === 'template'
338 );
339 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intbody
340 case 'tbody':
341 case 'thead':
342 case 'tfoot':
343 return (
344 tag === 'tr' ||
345 tag === 'style' ||
346 tag === 'script' ||
347 tag === 'template'
348 );
349 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incolgroup
350 case 'colgroup':
351 return tag === 'col' || tag === 'template';
352 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intable
353 case 'table':
354 return (
355 tag === 'caption' ||
356 tag === 'colgroup' ||
357 tag === 'tbody' ||
358 tag === 'tfoot' ||
359 tag === 'thead' ||
360 tag === 'style' ||
361 tag === 'script' ||
362 tag === 'template'
363 );
364 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inhead
365 case 'head':
366 return (
367 tag === 'base' ||
368 tag === 'basefont' ||
369 tag === 'bgsound' ||
370 tag === 'link' ||
371 tag === 'meta' ||
372 tag === 'title' ||
373 tag === 'noscript' ||
374 tag === 'noframes' ||
375 tag === 'style' ||
376 tag === 'script' ||
377 tag === 'template'
378 );
379 // https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
380 case 'html':
381 if (implicitRootScope) {
382 // When our parent tag is html and we're in the root scope we will actually
383 // insert most tags into the body so we need to fall through to validating
384 // the specific tag with "in body" parsing mode below
385 break;
386 }
387 return tag === 'head' || tag === 'body' || tag === 'frameset';
388 case 'frameset':
389 return tag === 'frame';
390 case '#document':
391 if (implicitRootScope) {
392 // When our parent is the Document and we're in the root scope we will actually
393 // insert most tags into the body so we need to fall through to validating
394 // the specific tag with "in body" parsing mode below
395 break;
396 }
397 return tag === 'html';
398 }
399
400 // Probably in the "in body" parsing mode, so we outlaw only tag combos
401 // where the parsing rules cause implicit opens or closes to be added.
402 // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
403 switch (tag) {
404 case 'h1':
405 case 'h2':
406 case 'h3':
407 case 'h4':
408 case 'h5':
409 case 'h6':
410 return (
411 parentTag !== 'h1' &&
412 parentTag !== 'h2' &&
413 parentTag !== 'h3' &&
414 parentTag !== 'h4' &&
415 parentTag !== 'h5' &&
416 parentTag !== 'h6'
417 );
418
419 case 'rp':
420 case 'rt':
421 // $FlowFixMe[incompatible-type]
422 return impliedEndTags.indexOf(parentTag) === -1;
423
424 case 'caption':
425 case 'col':
426 case 'colgroup':
427 case 'frameset':
428 case 'frame':
429 case 'tbody':
430 case 'td':
431 case 'tfoot':
432 case 'th':
433 case 'thead':
434 case 'tr':
435 // These tags are only valid with a few parents that have special child
436 // parsing rules -- if we're down here, then none of those matched and
437 // so we allow it only if we don't know what the parent is, as all other
438 // cases are invalid.
439 return parentTag == null;
440 case 'head':
441 // We support rendering <head> in the root when the container is
442 // #document, <html>, or <body>.
443 return implicitRootScope || parentTag === null;
444 case 'html':
445 // We support rendering <html> in the root when the container is
446 // #document
447 return (
448 (implicitRootScope && parentTag === '#document') || parentTag === null
449 );
450 case 'body':
451 // We support rendering <body> in the root when the container is
452 // #document or <html>
453 return (
454 (implicitRootScope &&
455 (parentTag === '#document' || parentTag === 'html')) ||
456 parentTag === null
457 );
458 }
459
460 return true;
461 }
462
463 /**
464 * Returns whether
465 */
466 function findInvalidAncestorForTag(
467 tag: string,
468 ancestorInfo: AncestorInfoDev,
469 ): ?Info {
470 switch (tag) {
471 case 'address':
472 case 'article':
473 case 'aside':
474 case 'blockquote':
475 case 'center':
476 case 'details':
477 case 'dialog':
478 case 'dir':
479 case 'div':
480 case 'dl':
481 case 'fieldset':
482 case 'figcaption':
483 case 'figure':
484 case 'footer':
485 case 'header':
486 case 'hgroup':
487 case 'main':
488 case 'menu':
489 case 'nav':
490 case 'ol':
491 case 'p':
492 case 'section':
493 case 'summary':
494 case 'ul':
495 case 'pre':
496 case 'listing':
497 case 'table':
498 case 'hr':
499 case 'xmp':
500 case 'h1':
501 case 'h2':
502 case 'h3':
503 case 'h4':
504 case 'h5':
505 case 'h6':
506 return ancestorInfo.pTagInButtonScope;
507
508 case 'form':
509 return ancestorInfo.formTag || ancestorInfo.pTagInButtonScope;
510
511 case 'li':
512 return ancestorInfo.listItemTagAutoclosing;
513
514 case 'dd':
515 case 'dt':
516 return ancestorInfo.dlItemTagAutoclosing;
517
518 case 'button':
519 return ancestorInfo.buttonTagInScope;
520
521 case 'a':
522 // Spec says something about storing a list of markers, but it sounds
523 // equivalent to this check.
524 return ancestorInfo.aTagInScope;
525
526 case 'nobr':
527 return ancestorInfo.nobrTagInScope;
528 }
529
530 return null;
531 }
532
533 const didWarn: {[string]: boolean} = {};
534
535 function findAncestor(parent: null | Fiber, tagName: string): null | Fiber {
536 while (parent) {
537 switch (parent.tag) {
538 case HostComponent:
539 case HostHoistable:
540 case HostSingleton:
541 if (parent.type === tagName) {
542 return parent;
543 }
544 }
545 parent = parent.return;
546 }
547 return null;
548 }
549
550 function validateDOMNesting(
551 childTag: string,
552 ancestorInfo: AncestorInfoDev,
553 ): boolean {
554 if (__DEV__) {
555 ancestorInfo = ancestorInfo || emptyAncestorInfoDev;
556 const parentInfo = ancestorInfo.current;
557 const parentTag = parentInfo && parentInfo.tag;
558
559 const invalidParent = isTagValidWithParent(
560 childTag,
561 parentTag,
562 ancestorInfo.implicitRootScope,
563 )
564 ? null
565 : parentInfo;
566 const invalidAncestor = invalidParent
567 ? null
568 : findInvalidAncestorForTag(childTag, ancestorInfo);
569 const invalidParentOrAncestor = invalidParent || invalidAncestor;
570 if (!invalidParentOrAncestor) {
571 return true;
572 }
573
574 const ancestorTag = invalidParentOrAncestor.tag;
575
576 const warnKey =
577 // eslint-disable-next-line react-internal/safe-string-coercion
578 String(!!invalidParent) + '|' + childTag + '|' + ancestorTag;
579 if (didWarn[warnKey]) {
580 return false;
581 }
582 didWarn[warnKey] = true;
583
584 const child = current;
585 const ancestor = child ? findAncestor(child.return, ancestorTag) : null;
586
587 const ancestorDescription =
588 child !== null && ancestor !== null
589 ? describeAncestors(ancestor, child, null)
590 : '';
591
592 const tagDisplayName = '<' + childTag + '>';
593 if (invalidParent) {
594 let info = '';
595 if (ancestorTag === 'table' && childTag === 'tr') {
596 info +=
597 ' Add a <tbody>, <thead> or <tfoot> to your code to match the DOM tree generated by ' +
598 'the browser.';
599 }
600 console.error(
601 'In HTML, %s cannot be a child of <%s>.%s\n' +
602 'This will cause a hydration error.%s',
603 tagDisplayName,
604 ancestorTag,
605 info,
606 ancestorDescription,
607 );
608 } else {
609 console.error(
610 'In HTML, %s cannot be a descendant of <%s>.\n' +
611 'This will cause a hydration error.%s',
612 tagDisplayName,
613 ancestorTag,
614 ancestorDescription,
615 );
616 }
617 if (child) {
618 // For debugging purposes find the nearest ancestor that caused the issue.
619 // The stack trace of this ancestor can be useful to find the cause.
620 // If the parent is a direct parent in the same owner, we don't bother.
621 const parent = child.return;
622 if (
623 ancestor !== null &&
624 parent !== null &&
625 (ancestor !== parent || parent._debugOwner !== child._debugOwner)
626 ) {
627 runWithFiberInDEV(ancestor, () => {
628 console.error(
629 // We repeat some context because this log might be taken out of context
630 // such as in React DevTools or grouped server logs.
631 '<%s> cannot contain a nested %s.\n' +
632 'See this log for the ancestor stack trace.',
633 ancestorTag,
634 tagDisplayName,
635 );
636 });
637 }
638 }
639 return false;
640 }
641 return true;
642 }
643
644 function validateTextNesting(
645 childText: string,
646 parentTag: string,
647 implicitRootScope: boolean,
648 ): boolean {
649 if (__DEV__) {
650 if (implicitRootScope || isTagValidWithParent('#text', parentTag, false)) {
651 return true;
652 }
653
654 const warnKey = '#text|' + parentTag;
655 if (didWarn[warnKey]) {
656 return false;
657 }
658 didWarn[warnKey] = true;
659
660 const child = current;
661 const ancestor = child ? findAncestor(child, parentTag) : null;
662
663 const ancestorDescription =
664 child !== null && ancestor !== null
665 ? describeAncestors(
666 ancestor,
667 child,
668 child.tag !== HostText ? {children: null} : null,
669 )
670 : '';
671
672 if (/\S/.test(childText)) {
673 console.error(
674 'In HTML, text nodes cannot be a child of <%s>.\n' +
675 'This will cause a hydration error.%s',
676 parentTag,
677 ancestorDescription,
678 );
679 } else {
680 console.error(
681 'In HTML, whitespace text nodes cannot be a child of <%s>. ' +
682 "Make sure you don't have any extra whitespace between tags on " +
683 'each line of your source code.\n' +
684 'This will cause a hydration error.%s',
685 parentTag,
686 ancestorDescription,
687 );
688 }
689 return false;
690 }
691 return true;
692 }
693
694 export {updatedAncestorInfoDev, validateDOMNesting, validateTextNesting};