Fix ShadowRoot child tree semantics

This commit is contained in:
SGavrl
2026-07-31 10:14:48 +00:00
parent 3e377cef67
commit 2117fa0b8c
2 changed files with 153 additions and 28 deletions

View File

@@ -1121,7 +1121,9 @@ class Node {
const t = this.nodeType;
if (t === 3 || t === 8) _dom("set_text_content", this._nid, String(v ?? ""));
}
get parentNode() { return _wrap(+_dom("parent_node", this._nid)); }
get parentNode() {
return this._shadowParent || _wrap(+_dom("parent_node", this._nid));
}
get parentElement() { const p = this.parentNode; return p && p.nodeType === 1 ? p : null; }
get childNodes() {
const ids = _domParse("child_nodes", this._nid) || [];
@@ -1129,8 +1131,22 @@ class Node {
}
get firstChild() { return _wrap(+_dom("first_child", this._nid)); }
get lastChild() { return _wrap(+_dom("last_child", this._nid)); }
get nextSibling() { return _wrap(+_dom("next_sibling", this._nid)); }
get previousSibling() { return _wrap(+_dom("prev_sibling", this._nid)); }
get nextSibling() {
if (this._shadowParent) {
const children = this._shadowParent.childNodes;
const index = children.indexOf(this);
return index >= 0 ? (children[index + 1] || null) : null;
}
return _wrap(+_dom("next_sibling", this._nid));
}
get previousSibling() {
if (this._shadowParent) {
const children = this._shadowParent.childNodes;
const index = children.indexOf(this);
return index > 0 ? children[index - 1] : null;
}
return _wrap(+_dom("prev_sibling", this._nid));
}
appendChild(c) {
if (!c) return c;
if (c instanceof DocumentFragment) {
@@ -1138,6 +1154,7 @@ class Node {
for (const child of children) this.appendChild(child);
return c;
}
if (c._shadowParent) c._shadowParent.removeChild(c);
_dom("append_child", this._nid, c._nid);
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []);
if (c instanceof Element && c.tagName === 'SCRIPT') {
@@ -1234,6 +1251,7 @@ class Node {
this.removeChild(oldChild);
return oldChild;
}
if (newChild._shadowParent) newChild._shadowParent.removeChild(newChild);
_dom("insert_before", newChild._nid, oldChild._nid);
_dom("remove_child", oldChild._nid);
return oldChild;
@@ -1246,6 +1264,7 @@ class Node {
for (const child of children) this.insertBefore(child, ref);
return n;
}
if (n._shadowParent) n._shadowParent.removeChild(n);
_dom("insert_before", n._nid, ref._nid);
return n;
}
@@ -1303,7 +1322,14 @@ class Node {
// -1 => this precedes other => other FOLLOWS this(4); +1 => this PRECEDING(2)).
return (+_dom("compare_order", this._nid, other._nid) < 0) ? 4 : 2;
}
getRootNode() { return globalThis.document; }
getRootNode(options) {
let root = this;
while (root.parentNode) root = root.parentNode;
if (options?.composed && root instanceof ShadowRoot) {
return root.host.getRootNode(options);
}
return root;
}
normalize() {
// Merge adjacent exclusive Text nodes, drop empty ones, recurse. Detached
// removed nodes keep their own data (read from the backing node by nid).
@@ -3154,12 +3180,8 @@ class Element extends Node {
}
getAnimations() { return []; }
get isConnected() {
var node = this;
while (node) {
if (node.nodeType === 9) return true;
node = node.parentNode;
}
return false;
const root = this.getRootNode({ composed: true });
return !!root && root.nodeType === 9;
}
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
append(...nodes) { for (const n of _convertNodes(nodes)) this.appendChild(n); }
@@ -5577,12 +5599,8 @@ if (!CharacterData.prototype.remove) CharacterData.prototype.remove = Element.pr
if (!('isConnected' in Node.prototype)) {
Object.defineProperty(Node.prototype, 'isConnected', {
get() {
let node = this;
while (node) {
if (node.nodeType === 9) return true; // Document node
node = node.parentNode;
}
return false;
const root = this.getRootNode({ composed: true });
return !!root && root.nodeType === 9;
}
});
}
@@ -8991,11 +9009,11 @@ Element.prototype.attachShadow = function attachShadow(opts) {
host: host,
get innerHTML() { return children.map(c => c.outerHTML || c.textContent || '').join(''); },
set innerHTML(v) {
children.length = 0;
while (children.length) shadow.removeChild(children[children.length - 1]);
if (v) {
const tmp = document.createElement('div');
tmp.innerHTML = v;
for (let i = 0; i < tmp.childNodes.length; i++) children.push(tmp.childNodes[i]);
for (const child of Array.from(tmp.childNodes)) shadow.appendChild(child);
}
},
get childNodes() { return children; },
@@ -9005,8 +9023,10 @@ Element.prototype.attachShadow = function attachShadow(opts) {
get children() { return children.filter(c => c.nodeType === 1); },
appendChild(c) {
if (c) {
if (c._shadowParent) c._shadowParent.removeChild(c);
else if (c.parentNode) c.parentNode.removeChild(c);
children.push(c);
try { c.parentNode = shadow; } catch (_) { /* parentNode is getter-only on Node, ignore */ }
c._shadowParent = shadow;
}
return c;
},
@@ -9015,18 +9035,30 @@ Element.prototype.attachShadow = function attachShadow(opts) {
if (!ref) { shadow.appendChild(n); return n; }
const idx = children.indexOf(ref);
if (idx >= 0) {
if (n._shadowParent) n._shadowParent.removeChild(n);
else if (n.parentNode) n.parentNode.removeChild(n);
children.splice(idx, 0, n);
try { n.parentNode = shadow; } catch (_) {}
n._shadowParent = shadow;
}
else shadow.appendChild(n);
return n;
},
removeChild(c) { const idx = children.indexOf(c); if (idx >= 0) children.splice(idx, 1); return c; },
removeChild(c) {
const idx = children.indexOf(c);
if (idx >= 0) {
children.splice(idx, 1);
if (c._shadowParent === shadow) c._shadowParent = null;
}
return c;
},
replaceChild(n, o) {
const idx = children.indexOf(o);
if (idx >= 0) {
if (n._shadowParent) n._shadowParent.removeChild(n);
else if (n.parentNode) n.parentNode.removeChild(n);
children[idx] = n;
try { n.parentNode = shadow; } catch (_) {}
n._shadowParent = shadow;
if (o._shadowParent === shadow) o._shadowParent = null;
}
return o;
},
@@ -9046,8 +9078,14 @@ Element.prototype.attachShadow = function attachShadow(opts) {
return results;
},
getElementById(id) { return shadow.querySelector('#' + id); },
contains(n) { return children.includes(n); },
getRootNode() { return shadow; },
contains(n) {
return n === shadow || children.some(
child => child === n || (child.contains && child.contains(n))
);
},
getRootNode(options) {
return options?.composed ? host.getRootNode(options) : shadow;
},
get ownerDocument() { return document; },
get nodeType() { return 11; }, // DOCUMENT_FRAGMENT_NODE
get nodeName() { return '#document-fragment'; },
@@ -9058,10 +9096,12 @@ Element.prototype.attachShadow = function attachShadow(opts) {
// these the inherited Node accessors run against this._nid. The setter in
// particular would target the host document and wipe it. Operate on the
// shadow's own `children` store instead.
get textContent() { return children.map(c => c.textContent || "").join(""); },
get textContent() {
return children.map(c => c.nodeType === 8 ? "" : (c.textContent || "")).join("");
},
set textContent(v) {
children.length = 0;
if (v != null && v !== "") children.push(document.createTextNode(String(v)));
while (children.length) shadow.removeChild(children[children.length - 1]);
if (v != null && v !== "") shadow.appendChild(document.createTextNode(String(v)));
},
hasChildNodes() { return children.length > 0; },
// A detached fragment id backs any inherited nid-based method we do not
@@ -10831,7 +10871,12 @@ globalThis.__obscura_init = function() {
globalThis.__virtualUrl = null;
_installWasmStreamingFallback();
globalThis.document = new Document(+_dom("document_node_id"));
const documentNid = +_dom("document_node_id");
globalThis.document = new Document(documentNid);
// parentNode on <html> reaches the backing document node. Keep that wrapper
// canonical so getRootNode(), isConnected, and identity comparisons return
// the same Document object exposed as globalThis.document.
_cache.set(documentNid, globalThis.document);
const scr = _fp('screen');
const sw = scr[0], sh = scr[1];

View File

@@ -1941,6 +1941,86 @@ mod tests {
);
}
#[test]
fn shadow_root_children_expose_parent_siblings_and_composed_root() {
let mut rt = setup_runtime("<html><body></body></html>");
let result = rt
.evaluate(
r#"
const host = document.createElement("lit-host");
document.body.appendChild(host);
const root = host.attachShadow({ mode: "open" });
const start = document.createComment("start");
const end = document.createComment("end");
root.appendChild(start);
root.appendChild(end);
const text = document.createTextNode("rendered");
start.parentNode.insertBefore(text, end);
const inserted = [
start.parentNode === root,
start.nextSibling === text,
text.previousSibling === start,
text.nextSibling === end,
end.previousSibling === text,
root.contains(text),
text.getRootNode() === root,
text.getRootNode({ composed: true }) === document,
root.getRootNode({ composed: true }) === document,
root.isConnected,
text.isConnected,
root.textContent
];
root.removeChild(text);
const removed = [
text.parentNode === null,
start.nextSibling === end,
end.previousSibling === start
];
document.body.appendChild(start);
const moved = [
start.parentNode === document.body,
start.getRootNode() === document,
root.firstChild === end
];
root.innerHTML = "<span id='inside'>inside</span>";
const inside = root.firstChild;
const parsed = [
inside.parentNode === root,
inside.getRootNode() === root,
root.textContent
];
return [inserted, removed, moved, parsed];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
[
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
"rendered"
],
[true, true, true],
[true, true, true],
[true, true, "inside"]
])
);
}
#[test]
fn create_element_synchronously_constructs_an_existing_definition() {
let mut rt = setup_runtime("<html><body></body></html>");