fix(dom): bridge <template> contents to JS so .content is not always empty (#463)

The parser already put template children in a separate contents document
per the HTML spec (tree_sink.rs), but nothing exposed that node, so
`get content()` fabricated an empty DocumentFragment and the parsed
markup was unreachable:

    document.body.innerHTML = '<template id=t><p>a</p></template>';
    t.content.childNodes.length   // 0 (Chrome: 1)
    t.innerHTML                   // "" (Chrome: '<p>a</p>')

Every <template>-driven UI — custom elements cloning
`template.content`, lit, Vue runtime templates, htmx fragments, Alpine
x-if/x-for — stamped nothing, with no error raised.

Three layers were involved:

- `DomTree::template_contents` returns the contents node, allocating one
  on demand so templates built via `createElement` work too. Exposed as
  the `template_contents` op; `get content()` wraps it as a
  DocumentFragment cached by node id, so `.content` keeps a stable
  identity across reads.

- `import_node_from` cloned the element data verbatim, carrying the
  *source* tree's contents NodeId into the destination tree, where it
  indexed whatever unrelated node occupied that slot. That is why a
  template injected via innerHTML resolved `.content` to a bogus node
  rather than merely an empty one. The contents are now imported into a
  fresh node and the reference remapped, queued onto the existing
  worklist so nested templates stay iterative.

- The serializer emitted a template's own (always empty) children, so
  contents vanished from outerHTML/innerHTML and `cloneNode(true)` —
  which round-trips through outer_html — produced an empty template.
  `content_source` resolves a template to its contents document for both
  inner_html and outer_html.

With serialization fixed on the Rust side, the `set innerHTML` template
branch that routed to `.content` now has a matching getter for free, so
no template special case is needed on the JS hot path.
This commit is contained in:
Andrey Mnatsakanov committed 2026-07-24 08:31:55 +02:00
1 parent 485497393d
commit ae438e196b
5 files changed
+231 -2

No files matched your search

+15 -2
View File
@@ -21,10 +21,21 @@ impl DomTree {
pub fn inner_html(&self, node_id: NodeId) -> String {
let mut buf = String::new();
self.serialize_worklist(self.child_work(node_id), &mut buf);
self.serialize_worklist(self.child_work(self.content_source(node_id)), &mut buf);
buf
}
// The node whose children represent `node_id`'s markup. For a <template>
// that is its contents document, since the parser puts template children
// there rather than under the element (issue #463); for everything else it
// is the node itself.
fn content_source(&self, node_id: NodeId) -> NodeId {
match self.get_node(node_id).map(|n| n.data) {
Some(NodeData::Element { template_contents: Some(contents), .. }) => contents,
_ => node_id,
}
}
// Children as work items in document order (top of the LIFO stack first).
fn child_work(&self, node_id: NodeId) -> Vec<SerializeWork> {
self.children(node_id)
@@ -101,7 +112,9 @@ impl DomTree {
if include_self {
stack.push(SerializeWork::CloseTag(tag.to_string()));
}
for w in self.child_work(node_id) {
// A <template> serializes its contents document, not its
// own (always empty) children — issue #463.
for w in self.child_work(self.content_source(node_id)) {
stack.push(w);
}
}