packages/react-dom-bindings/src/client/validateDOMNesting.js JAVASCRIPT 695 lines View on github.com → Search inside
1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 *7 * @flow8 */910import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';11import type {HydrationDiffNode} from 'react-reconciler/src/ReactFiberHydrationDiffs';1213import {14  current,15  runWithFiberInDEV,16} from 'react-reconciler/src/ReactCurrentFiber';17import {18  HostComponent,19  HostHoistable,20  HostSingleton,21  HostText,22} from 'react-reconciler/src/ReactWorkTags';2324import {describeDiff} from 'react-reconciler/src/ReactFiberHydrationDiffs';2526function 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}5657type Info = {tag: string};58export type AncestorInfoDev = {59  current: ?Info,6061  formTag: ?Info,62  aTagInScope: ?Info,63  buttonTagInScope: ?Info,64  nobrTagInScope: ?Info,65  pTagInButtonScope: ?Info,6667  listItemTagAutoclosing: ?Info,68  dlItemTagAutoclosing: ?Info,6970  // <head> or <body>71  containerTagInScope: ?Info,72  implicitRootScope: boolean,73};7475// This validation code was written based on the HTML5 parsing spec:76// https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope77//78// Note: this does not catch all invalid nesting, nor does it try to (as it's79// not clear what practical benefit doing so provides); instead, we warn only80// for cases where the parser will give a parse tree differing from what React81// intended. For example, <b><div></div></b> is invalid but we don't warn82// because it still parses correctly; we do warn for other cases like nested83// <p> tags where the beginning of the second element implicitly closes the84// first, causing a confusing mess.8586// https://html.spec.whatwg.org/multipage/syntax.html#special87const 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];172173// https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope174const inScopeTags = [175  'applet',176  'caption',177  'html',178  'table',179  'td',180  'th',181  'marquee',182  'object',183  'template',184185  // https://html.spec.whatwg.org/multipage/syntax.html#html-integration-point186  // TODO: Distinguish by namespace here -- for <title>, including it here187  // errs on the side of fewer warnings188  'foreignObject',189  'desc',190  'title',191];192193// https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-button-scope194const buttonScopeTags = __DEV__ ? inScopeTags.concat(['button']) : [];195196// https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags197const impliedEndTags = [198  'dd',199  'dt',200  'li',201  'option',202  'optgroup',203  'p',204  'rp',205  'rt',206];207208const emptyAncestorInfoDev: AncestorInfoDev = {209  current: null,210211  formTag: null,212  aTagInScope: null,213  buttonTagInScope: null,214  nobrTagInScope: null,215  pTagInButtonScope: null,216217  listItemTagAutoclosing: null,218  dlItemTagAutoclosing: null,219220  containerTagInScope: null,221  implicitRootScope: false,222};223224function updatedAncestorInfoDev(225  oldInfo: null | AncestorInfoDev,226  tag: string,227): AncestorInfoDev {228  if (__DEV__) {229    const ancestorInfo = {...(oldInfo || emptyAncestorInfoDev)};230    const info = {tag};231232    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    }240241    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 in248      // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody249      ancestorInfo.listItemTagAutoclosing = null;250      ancestorInfo.dlItemTagAutoclosing = null;251    }252253    ancestorInfo.current = info;254255    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    }281282    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 where287      // you can escape the head by rendering a body singleton so we treat it like a normal scope288      ancestorInfo.implicitRootScope = true;289    } else if (ancestorInfo.implicitRootScope === true) {290      ancestorInfo.implicitRootScope = false;291    }292293    return ancestorInfo;294  } else {295    return null as any;296  }297}298299/**300 * Returns whether301 */302function 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-inselect310    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    // but323    case 'option':324      return tag === '#text';325    // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd326    // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incaption327    // No special behavior since these rules fall back to "in body" mode for328    // all except special table nodes which cause bad parsing behavior anyway.329330    // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intr331    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-intbody340    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-incolgroup350    case 'colgroup':351      return tag === 'col' || tag === 'template';352    // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intable353    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-inhead365    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-element380    case 'html':381      if (implicitRootScope) {382        // When our parent tag is html and we're in the root scope we will actually383        // insert most tags into the body so we need to fall through to validating384        // the specific tag with "in body" parsing mode below385        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 actually393        // insert most tags into the body so we need to fall through to validating394        // the specific tag with "in body" parsing mode below395        break;396      }397      return tag === 'html';398  }399400  // Probably in the "in body" parsing mode, so we outlaw only tag combos401  // where the parsing rules cause implicit opens or closes to be added.402  // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody403  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      );418419    case 'rp':420    case 'rt':421      // $FlowFixMe[incompatible-type]422      return impliedEndTags.indexOf(parentTag) === -1;423424    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 child436      // parsing rules -- if we're down here, then none of those matched and437      // so we allow it only if we don't know what the parent is, as all other438      // cases are invalid.439      return parentTag == null;440    case 'head':441      // We support rendering <head> in the root when the container is442      // #document, <html>, or <body>.443      return implicitRootScope || parentTag === null;444    case 'html':445      // We support rendering <html> in the root when the container is446      // #document447      return (448        (implicitRootScope && parentTag === '#document') || parentTag === null449      );450    case 'body':451      // We support rendering <body> in the root when the container is452      // #document or <html>453      return (454        (implicitRootScope &&455          (parentTag === '#document' || parentTag === 'html')) ||456        parentTag === null457      );458  }459460  return true;461}462463/**464 * Returns whether465 */466function 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;507508    case 'form':509      return ancestorInfo.formTag || ancestorInfo.pTagInButtonScope;510511    case 'li':512      return ancestorInfo.listItemTagAutoclosing;513514    case 'dd':515    case 'dt':516      return ancestorInfo.dlItemTagAutoclosing;517518    case 'button':519      return ancestorInfo.buttonTagInScope;520521    case 'a':522      // Spec says something about storing a list of markers, but it sounds523      // equivalent to this check.524      return ancestorInfo.aTagInScope;525526    case 'nobr':527      return ancestorInfo.nobrTagInScope;528  }529530  return null;531}532533const didWarn: {[string]: boolean} = {};534535function 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}549550function 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;558559    const invalidParent = isTagValidWithParent(560      childTag,561      parentTag,562      ancestorInfo.implicitRootScope,563    )564      ? null565      : parentInfo;566    const invalidAncestor = invalidParent567      ? null568      : findInvalidAncestorForTag(childTag, ancestorInfo);569    const invalidParentOrAncestor = invalidParent || invalidAncestor;570    if (!invalidParentOrAncestor) {571      return true;572    }573574    const ancestorTag = invalidParentOrAncestor.tag;575576    const warnKey =577      // eslint-disable-next-line react-internal/safe-string-coercion578      String(!!invalidParent) + '|' + childTag + '|' + ancestorTag;579    if (didWarn[warnKey]) {580      return false;581    }582    didWarn[warnKey] = true;583584    const child = current;585    const ancestor = child ? findAncestor(child.return, ancestorTag) : null;586587    const ancestorDescription =588      child !== null && ancestor !== null589        ? describeAncestors(ancestor, child, null)590        : '';591592    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 context630            // 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}643644function 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    }653654    const warnKey = '#text|' + parentTag;655    if (didWarn[warnKey]) {656      return false;657    }658    didWarn[warnKey] = true;659660    const child = current;661    const ancestor = child ? findAncestor(child, parentTag) : null;662663    const ancestorDescription =664      child !== null && ancestor !== null665        ? describeAncestors(666            ancestor,667            child,668            child.tag !== HostText ? {children: null} : null,669          )670        : '';671672    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}693694export {updatedAncestorInfoDev, validateDOMNesting, validateTextNesting};

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.