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);
}
}
+67
View File
@@ -603,6 +603,41 @@ impl DomTree {
None
}
/// The node holding a `<template>` element's contents.
///
/// The parser puts template children in a separate contents document rather
/// than under the element (HTML spec), so this is the only way to reach
/// them. Templates built with `createElement` have no contents node yet, so
/// one is allocated on demand — `.content` must be usable either way.
///
/// Returns `None` for a non-element node.
pub fn template_contents(&self, node_id: NodeId) -> Option<NodeId> {
{
let inner = self.inner.borrow();
let node = inner.nodes.get(node_id.index())?.as_ref()?;
match &node.data {
NodeData::Element { template_contents, .. } => {
if let Some(existing) = *template_contents {
return Some(existing);
}
}
_ => return None,
}
}
// Borrow released above: `new_node` takes its own mutable borrow.
// Matches what the tree sink allocates for a parsed template.
let contents = self.new_node(NodeData::Document);
let mut inner = self.inner.borrow_mut();
if let Some(Some(node)) = inner.nodes.get_mut(node_id.index()) {
if let NodeData::Element { template_contents, .. } = &mut node.data {
*template_contents = Some(contents);
return Some(contents);
}
}
None
}
pub fn ancestors(&self, node_id: NodeId) -> Vec<NodeId> {
let inner = self.inner.borrow();
let mut result = Vec::new();
@@ -727,6 +762,38 @@ impl DomTree {
let new_id = self.new_node(node_data);
self.append_child(dest_parent, new_id);
// A <template>'s children hang off a separate contents document, so
// the child walk below never reaches them. Worse, the cloned data
// carries the *source* tree's contents NodeId, which here indexes
// whatever unrelated node occupies that slot. Allocate a real
// contents node and queue the source contents' children into it, so
// the reference is remapped rather than left dangling (issue #463).
let src_contents = {
let inner = self.inner.borrow();
match inner.nodes.get(new_id.index()).and_then(|n| n.as_ref()) {
Some(node) => match &node.data {
NodeData::Element { template_contents, .. } => *template_contents,
_ => None,
},
None => None,
}
};
if let Some(src_contents) = src_contents {
let dest_contents = self.new_node(NodeData::Document);
{
let mut inner = self.inner.borrow_mut();
if let Some(Some(node)) = inner.nodes.get_mut(new_id.index()) {
if let NodeData::Element { template_contents, .. } = &mut node.data {
*template_contents = Some(dest_contents);
}
}
}
// Onto the same stack, so nested templates stay iterative.
for child_id in source.children(src_contents).into_iter().rev() {
stack.push((dest_contents, child_id));
}
}
for child_id in source.children(src_id).into_iter().rev() {
stack.push((new_id, child_id));
}
+14
View File
@@ -1216,6 +1216,8 @@ class Element extends Node {
if (this.localName === "svg") return "http://www.w3.org/2000/svg";
return "http://www.w3.org/1999/xhtml";
}
// `inner_html` resolves a <template> to its contents document on the Rust
// side (issue #463), so this needs no template special case.
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
set innerHTML(v) {
if (this.localName === 'template') {
@@ -1254,6 +1256,18 @@ class Element extends Node {
// infinite retry loop (issue #210).
const tag = this.localName;
if (tag === 'template') {
// Back the fragment with the node's real template contents (issue #463).
// The parser stores template children in a separate contents document
// instead of under the element, so without this the getter handed back a
// fabricated empty fragment and the parsed markup was unreachable.
// `template_contents` allocates one on demand for created templates.
const nid = +_dom("template_contents", this._nid);
if (nid >= 0) {
// Cache by node id so `.content` keeps a stable identity across reads —
// frameworks stash the fragment and compare it later.
if (!_cache.has(nid)) _cache.set(nid, new DocumentFragment(nid));
return _cache.get(nid);
}
if (!this._templateContent) this._templateContent = document.createDocumentFragment();
return this._templateContent;
}
+9
View File
@@ -392,6 +392,15 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
});
"true".into()
}
// A <template>'s children live in a separate contents document, so this
// is the only route to them from JS. Allocates one on demand for
// templates built via createElement.
"template_contents" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.template_contents(NodeId::new(nid))
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"create_document_fragment" => {
dom.new_node(NodeData::Document).index().to_string()
}
+126
View File
@@ -1541,6 +1541,132 @@ mod tests {
assert_eq!(result, serde_json::json!(["P", "A"]));
}
/// Issue #463: `<template>` contents are parsed into the node's
/// `template_contents` document, but no op exposed it, so `.content` handed
/// back a fabricated empty fragment and the parsed markup was unreachable.
#[test]
fn template_content_exposes_parsed_markup() {
let mut rt = setup_runtime(
r#"<body><template id="t"><p class="row">a</p><p class="row">b</p></template></body>"#,
);
let result = rt
.evaluate(
r#"
const t = document.getElementById('t');
return [
t.content.childNodes.length,
t.content.querySelectorAll('.row').length,
t.content.firstElementChild.textContent,
t.innerHTML,
t.content.nodeType,
t.content instanceof DocumentFragment,
// Identity is stable: frameworks stash `.content` and reuse it.
t.content === t.content,
// The children stay off the element itself, per the HTML spec.
t.childNodes.length,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
2,
2,
"a",
r#"<p class="row">a</p><p class="row">b</p>"#,
11,
true,
true,
0
])
);
}
/// Issue #463: the same must hold for a template that arrives via innerHTML
/// rather than the initial document parse — that is how most frameworks
/// inject templates.
#[test]
fn template_content_works_for_templates_added_via_inner_html() {
let mut rt = setup_runtime(r#"<body><div id="host"></div></body>"#);
let result = rt
.evaluate(
r#"
const host = document.getElementById('host');
host.innerHTML = '<template id="t2"><li class="item">x</li></template>';
const t = document.getElementById('t2');
const stamped = t.content.cloneNode(true);
host.appendChild(stamped);
return [
t.content.childNodes.length,
t.content.querySelector('.item').textContent,
host.querySelectorAll('li.item').length,
];
"#,
)
.unwrap();
// cloneNode(true) of the content is the canonical stamping idiom.
assert_eq!(result, serde_json::json!([1, "x", 1]));
}
/// Issue #463: a template built with createElement has no parsed contents,
/// so `.content` must allocate a backing fragment on demand and round-trip
/// through innerHTML.
#[test]
fn template_content_round_trips_for_created_templates() {
let mut rt = setup_runtime(r#"<body></body>"#);
let result = rt
.evaluate(
r#"
const t = document.createElement('template');
t.innerHTML = '<span class="s">hi</span>';
return [
t.content.childNodes.length,
t.content.querySelector('.s').textContent,
t.innerHTML,
t.childNodes.length,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([1, "hi", r#"<span class="s">hi</span>"#, 0])
);
}
/// Issue #463: serializing a `<template>` must emit its contents, or the
/// markup silently disappears from outerHTML/innerHTML round-trips — and
/// `cloneNode(true)`, which round-trips through outer_html, yields an empty
/// template.
#[test]
fn template_contents_survive_serialization_and_clone() {
let mut rt = setup_runtime(
r#"<body><template id="t"><li class="item">x</li></template></body>"#,
);
let result = rt
.evaluate(
r#"
const t = document.getElementById('t');
const clone = t.cloneNode(true);
return [
t.outerHTML,
document.body.innerHTML,
clone.content.childNodes.length,
clone.content.querySelector('.item').textContent,
// The clone's contents are its own, not shared with the original.
(clone.content.firstElementChild === t.content.firstElementChild),
];
"#,
)
.unwrap();
let expected = r#"<template id="t"><li class="item">x</li></template>"#;
assert_eq!(
result,
serde_json::json!([expected, expected, 1, "x", false])
);
}
#[test]
fn append_child_flattens_document_fragment() {
let mut rt = setup_runtime(r#"<main id="host"></main>"#);