Answer Key — Foster Parenting

⛔ STOP

Do not read this until docs/observation.md §8 is written.

The value of this lab is entirely in the gap between your prediction and reality. Reading first converts a 3-hour skill-building exercise into 10 minutes of trivia you will forget. Nobody is checking. That is the point.
































Case A — <table>hello<div>world</div></table>

body
  #text "hello"
  div
    #text "world"
  table

table.parentNode = body · table.childNodes.length = 0

Trace:

  1. <table> inserted in in body; insertion mode → in table.
  2. "hello" character token, current node is table → pending table character tokens cleared, original insertion mode saved, mode → in table text.
  3. Characters accumulate. The <div> start tag ends the run. Because the pending buffer contains a non-whitespace character, it is a parse error and the whole buffer is reprocessed through in table's "anything else" — i.e. foster parented before the table. Mode reverts and <div> is reprocessed.
  4. <div> in in table → "anything else" → foster parenting enabled → processed with in body rules → inserted before the table, pushed onto the stack of open elements. The insertion mode stays in table.
  5. "world": mode is still in table, but the current node is now div, not a table-ish element — so the foster-parenting redirect does not apply (it only fires when the target is table/tbody/tfoot/thead/tr). The text lands inside the div.

Step 5 is the one almost everyone gets wrong. Foster parenting is a property of the insertion location, not of the insertion mode.


Case B — <table><input type="hidden"><input type="text"></table>

body
  input [type="text" name="b"]
  table
    input [type="hidden" name="a"]

table.childNodes.length = 1

The hidden input is the only element in this lab that stays inside the table. in table has an explicit rule for <input>: if it has a type attribute case-insensitively equal to hidden, parse error, insert the element, pop it. Otherwise fall through to "anything else" and get foster parented.

This is the §46 payload. There is no structural or rendering reason a hidden input is safer inside a table than a text input — it renders nothing either way. The rule exists because a large amount of legacy server-generated HTML emitted hidden form fields directly inside <table> markup, and relocating them out of the table moved them out of the enclosing <form>, silently dropping fields on submit. The spec encoded the compatibility requirement. This is a fossil, not a principle — and your notebook entry should say so, with the CL/spec history as evidence.


Case C — the multiline example

body
  #text "\n    \n      hello\n      "
  div
    #text "world"
  table
    #text "\n    "
  #text "\n  \n\n"

Text nodes in the body subtree: 4 · table has 1 child.

Three things worth having missed:

  • The first text node is a merge. "\n " (between <body> and <table>) was already in body when the foster-parented "\n hello\n " arrived. Inserting a character appends to the Text node immediately before the insertion location if one exists, so they coalesce into a single node rather than becoming two siblings.
  • The table is not empty. The whitespace between </div> and </table> is buffered by in table text, and because that run is entirely whitespace it is inserted normally — into the table. Whitespace stays; non-whitespace gets evicted. That asymmetry is the whole reason the sub-mode exists.
  • The trailing text node keeps growing after </body>. </body> switches the mode to after body but does not pop body off the stack, so whitespace after </body> and after </html> still appends to body's last Text node.

If your browser observation disagrees with this derivation, trust the observation — and then treat the disagreement as a genuine finding worth chasing to the source. That is a better outcome than agreeing with me.


Case D — <table><tr><td>a</td></tr></table>

body
  table
    tbody
      tr
        td
          #text "a"

tbody was never written. in table handling a td/th/tr start tag inserts a tbody element for a synthesized start tag with no attributes, switches to in table body, and reprocesses the token.


Verified against main on 2026-08-10. Everything below lives in third_party/blink/renderer/core/html/parser/.

RoleNameFile
Performs relocationHTMLConstructionSite::FosterParent(Node*)html_construction_site.cc
Locates the siteHTMLConstructionSite::FindFosterSite(HTMLConstructionSiteTask&)html_construction_site.cc
Decides relocationHTMLConstructionSite::ShouldFosterParent()html_construction_site.cc
Per-element predicateHTMLStackItem::CausesFosterParenting()html_stack_item.h
In-table start tagsHTMLTreeBuilder::ProcessStartTagForInTable(AtomicHTMLToken*)html_tree_builder.cc
Flushes buffered charsHTMLTreeBuilder::DefaultForInTableText()html_tree_builder.cc
Bufferpending_table_characters_html_tree_builder.cc
bool HTMLConstructionSite::ShouldFosterParent() const {
  return redirect_attach_to_foster_parent_ &&
      CurrentStackItem()->IsElementNode() &&
      CurrentStackItem()->CausesFosterParenting();
}

Three conditions, and they map exactly onto the spec: redirect_attach_to_foster_parent_ is the "foster parenting enabled" flag the tree builder toggles around the "anything else" path; CausesFosterParenting() is the table-ish check. This is why Case A step 5 behaves as it does — the flag is on, but the current node is a div, so the predicate is false.

FindFosterSite checks open_elements_.Topmost(kTemplate) before Topmost(kTable). That template check is not in the naive reading of the algorithm and is worth a second look — it is the answer to gate question 8.

The Case B predicate:

case HTMLTag::kInput: {
    Attribute* type_attribute = token->GetAttributeItem(html_names::kTypeAttr);
    if (type_attribute &&
        EqualIgnoringAsciiCase(type_attribute->Value(), "hidden")) {
        ParseError(token);
        tree_.InsertSelfClosingHTMLElementDestroyingToken(token);
        return;
    }
    // break to hit "anything else" case.
    break;
}

Note FosterParent does not mutate the DOM directly — it builds an HTMLConstructionSiteTask and calls QueueTask(task, true). Tree construction is deferred and batched, not immediate. If you predicted a direct appendChild-style call, that gap is your next question: why does the construction site queue instead of mutate? (Mutation observers, custom element reactions, and script reentrancy all live in that answer.)

Search strategies that work here

  • Spec-phrase search: "foster parent" in Code Search lands directly on the implementation, because Blink names its parser methods after spec terms of art.
  • symbol:ShouldFosterParent → declaration, then the references panel gives callers and callees mechanically. Never guess gate questions 3 and 4.
  • The // break to hit "anything else" case. comment is a good example of Blink annotating a spec fallthrough — grepping for anything else finds many of these.

Gate question 5 — process and thread

Renderer process, main thread — for tree construction. The nuance the lab hints at is that this is not true of the whole parser: the directory contains background_html_scanner.cc, and tokenization can run off the main thread to drive preload scanning. There is no background_html_parser.cc — the historical fully-threaded HTML parser is gone. Tree building stays on the main thread because it must interleave with synchronous script execution and observable DOM state.

Verify this yourself rather than taking it from here: check what BackgroundHTMLScanner actually produces and who consumes it. That is a good five-minute Code Search exercise and a direct rehearsal for §18.


What to do if you scored badly

Nothing. A first-lab prediction that is structurally right and detail-wrong is the expected result, and Case C is genuinely hard. What matters is whether §4's search queries are recorded — those transfer to every remaining lab in the track. The DOM trees do not.