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:
1 parent
485497393d
commit
ae438e196b
5 files changed
+231
-2
No files matched your search
Vendored
+14
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>"#);
|
||||
|
||||
Reference in new issue
Block a user