From a2fd4d17356a58d6a4df2cb56b7ef7333f8c7754 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:54:51 +0000 Subject: [PATCH] fix(js): parse fragments in their insertion context --- crates/obscura-dom/src/lib.rs | 2 +- crates/obscura-dom/src/tree_sink.rs | 10 +++-- crates/obscura-js/js/bootstrap.js | 58 ++++++++--------------------- crates/obscura-js/src/ops.rs | 11 +++++- crates/obscura-js/src/runtime.rs | 26 +++++++++++++ 5 files changed, 60 insertions(+), 47 deletions(-) diff --git a/crates/obscura-dom/src/lib.rs b/crates/obscura-dom/src/lib.rs index 6d3d6fc..6ad84be 100644 --- a/crates/obscura-dom/src/lib.rs +++ b/crates/obscura-dom/src/lib.rs @@ -7,4 +7,4 @@ pub mod selector; pub mod serialize; pub use tree::{Attribute, DomTree, Node, NodeData, NodeId}; -pub use tree_sink::{parse_html, parse_fragment}; +pub use tree_sink::{parse_fragment, parse_fragment_with_context, parse_html}; diff --git a/crates/obscura-dom/src/tree_sink.rs b/crates/obscura-dom/src/tree_sink.rs index 674732f..4909579 100644 --- a/crates/obscura-dom/src/tree_sink.rs +++ b/crates/obscura-dom/src/tree_sink.rs @@ -243,10 +243,14 @@ pub fn parse_html(html: &str) -> DomTree { } pub fn parse_fragment(html: &str) -> DomTree { - use html5ever::tendril::TendrilSink; - use html5ever::{parse_fragment, ParseOpts, QualName}; - let context_name = QualName::new(None, ns!(html), local_name!("body")); + parse_fragment_with_context(html, context_name) +} + +pub fn parse_fragment_with_context(html: &str, context_name: QualName) -> DomTree { + use html5ever::tendril::TendrilSink; + use html5ever::{parse_fragment, ParseOpts}; + let tree = DomTree::new(); parse_fragment(tree, ParseOpts::default(), context_name, vec![]) .from_utf8() diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 00b3b27..1baa768 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1227,44 +1227,20 @@ function _isSubmitButton(el) { return false; } -// Ancestor chains required to parse table/select children, which the fragment -// parser drops when parsed loosely (it uses a fixed context). Each entry -// is [openTags, closeTags, depth] — depth is how many lastChild hops reach the -// container holding the parsed rows/cells/options. Classic jQuery wrapMap. -const _wrapMap = { - option: ['', 1], - optgroup: ['', 1], - tr: ['', '
', 2], - td: ['', '
', 3], - th: ['', '
', 3], - tbody: ['', '
', 1], - thead: ['', '
', 1], - tfoot: ['', '
', 1], - caption: ['', '
', 1], - colgroup: ['', '
', 1], - col: ['', '
', 2], -}; -// Parse an HTML string into an array of detached nodes. Table/select fragments -// are wrapped in the required ancestor chain and dug back out so their children -// survive; the wrapper is chosen from the fragment's own leading tag, falling -// back to the insertion context element's tag. -function _parseHTMLFragment(html, contextTag) { +// Parse an HTML string into detached nodes using the actual insertion element +// as html5ever's fragment context. This preserves table/select parsing rules, +// comments, text-node order, and foreign-content namespaces without a wrap map. +function _parseHTMLFragment(html, context) { html = String(html == null ? '' : html); - const m = /^\s*<\s*([a-zA-Z][a-zA-Z0-9]*)/.exec(html); - const lead = m ? m[1].toLowerCase() : ''; - const key = _wrapMap[lead] ? lead : (contextTag ? String(contextTag).toLowerCase() : ''); - const wrap = _wrapMap[key]; - const tmp = document.createElement('div'); - let container = tmp; - if (wrap) { - tmp.innerHTML = wrap[0] + html + wrap[1]; - for (let d = 0; d < wrap[2]; d++) container = container.lastChild || container; - } else { - tmp.innerHTML = html; - } + const ns = context && context.nodeType === 1 ? context.namespaceURI : null; + const tag = context && context.nodeType === 1 ? context.localName : 'body'; + const tmp = ns && ns !== 'http://www.w3.org/1999/xhtml' + ? document.createElementNS(ns, tag) + : document.createElement(tag); + tmp.innerHTML = html; const out = []; let child; - while ((child = container.firstChild)) out.push(container.removeChild(child)); + while ((child = tmp.firstChild)) out.push(tmp.removeChild(child)); return out; } @@ -1526,23 +1502,21 @@ class Element extends Node { // table/select fragments keep the right parsing context (_parseHTMLFragment). const pos = String(position).toLowerCase(); const parent = this.parentNode; - const contextTag = (pos === 'beforebegin' || pos === 'afterend') - ? (parent && parent.nodeType === 1 ? parent.tagName : 'body') - : this.tagName; + const context = (pos === 'beforebegin' || pos === 'afterend') ? parent : this; switch (pos) { case 'beforebegin': - if (parent) for (const n of _parseHTMLFragment(html, contextTag)) parent.insertBefore(n, this); + if (parent) for (const n of _parseHTMLFragment(html, context)) parent.insertBefore(n, this); break; case 'afterbegin': { const first = this.firstChild; - for (const n of _parseHTMLFragment(html, contextTag)) this.insertBefore(n, first); + for (const n of _parseHTMLFragment(html, context)) this.insertBefore(n, first); break; } case 'beforeend': - for (const n of _parseHTMLFragment(html, contextTag)) this.appendChild(n); + for (const n of _parseHTMLFragment(html, context)) this.appendChild(n); break; case 'afterend': - if (parent) { const next = this.nextSibling; for (const n of _parseHTMLFragment(html, contextTag)) parent.insertBefore(n, next); } + if (parent) { const next = this.nextSibling; for (const n of _parseHTMLFragment(html, context)) parent.insertBefore(n, next); } break; default: throw new DOMException( diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 64aad05..37e5cc7 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -391,7 +391,16 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str dom.detach(child); } if !arg2.is_empty() { - let fragment = obscura_dom::parse_fragment(&arg2); + let context_name = dom + .with_node(target, |node| match &node.data { + NodeData::Element { name, .. } => Some(name.clone()), + _ => None, + }) + .flatten(); + let fragment = match context_name { + Some(name) => obscura_dom::parse_fragment_with_context(&arg2, name), + None => obscura_dom::parse_fragment(&arg2), + }; let import_root = fragment.find_body_or_root(); dom.import_children_from(target, &fragment, import_root); } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 2e022c0..8f6dc5c 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1346,6 +1346,32 @@ mod tests { assert_eq!(out, serde_json::json!("SyntaxError")); } + #[test] + fn insert_adjacent_html_keeps_leading_comments_in_table_contexts() { + let mut rt = setup_runtime( + r#"
"#, + ); + let out = rt + .evaluate( + "(function(){var tb=document.getElementById('tb');tb.insertAdjacentHTML('beforeend','v');var row=document.getElementById('row');row.insertAdjacentHTML('beforeend','x');return Array.from(tb.childNodes).map(function(n){return n.nodeName}).join('|')+';'+Array.from(row.childNodes).map(function(n){return n.nodeName}).join('|');})()", + ) + .unwrap(); + assert_eq!(out, serde_json::json!("TR|#comment|TR;#comment|TD")); + } + + #[test] + fn insert_adjacent_html_uses_the_insertion_element_as_context() { + let mut rt = setup_runtime( + r#"
"#, + ); + let out = rt + .evaluate( + "(function(){var d=document.getElementById('d');d.insertAdjacentHTML('beforeend','v');var table=document.getElementById('table');table.insertAdjacentHTML('beforeend','x');var tb=document.getElementById('tb');tb.insertAdjacentHTML('beforeend','ytail');return d.firstChild.nodeName+':'+d.textContent+';'+table.lastElementChild.tagName+';'+Array.from(tb.childNodes).map(function(n){return n.nodeName+(n.data?':'+n.data:'')}).join('|');})()", + ) + .unwrap(); + assert_eq!(out, serde_json::json!("#text:v;TBODY;TR|#text:tail")); + } + #[test] fn test_document_title() { let mut rt = setup_runtime("Test");