From 5135e3cb4d74a67679ffdaf5ea23fde03bd6c1bb Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:39:18 +0000 Subject: [PATCH] perf: DOM read fast paths (constant nodeType, borrowed op reads, base64 table) (#259) * DOM read fast paths: constant nodeType, fewer ops and allocations - Element.nodeType is now a constant getter instead of inheriting Node's dynamic op-backed getter. nodeType is invariant (1) for element wrappers (wrappers are only built for element nodes and node ids are never freed and reused), so this drops one JS/Rust op per read on a pervasive path. Micro-bench: ~600k nodeType reads go from ~380ms to ~5ms. - attributes getter reads each attribute once instead of twice. - dataset Proxy is cached per element instead of reallocated on every access, which also makes el.dataset === el.dataset (spec-correct). - stylesheet scan borrows the node (with_node) instead of deep cloning it and compares rel via eq_ignore_ascii_case, dropping a clone and a lowercased String alloc per link per navigation. No regression: obstacle course 33/33, WPT dom/nodes Node- subset identical to baseline (3551/4163 subtests), targeted DOM probe identical. * DOM op reads: borrow the node instead of deep-cloning it The scalar read arms in op_dom (node_type, node_name, tag_name, get_attribute, attribute_names, parent/first/last/next/prev lookups, has_child_nodes, pi_target, doctype_name, doctype_public_id) and the node_root / node_ancestors / node_child_index walks called get_node, which clones the whole Node (for elements a deep clone of the attribute Vec and its Strings) just to read one field. Switch them to with_node, which borrows under the RefCell. element_children and the document_element / document_doctype child scans stay on get_node on purpose (they iterate, so the borrow story is left untouched). Output is identical. Traversal/attribute micro-bench ~25-30% faster with fewer allocations per op. No regression: obstacle course 33/33, WPT dom/nodes identical to baseline (7057/10620 across 314 files). * base64 decode: reverse-lookup table instead of indexOf scan _base64ToUint8Array decodes every fetch/XHR response body. It ran an O(64) alphabet.indexOf plus a one-char substring alloc per character. Build a charCode->6-bit reverse table once at module load and index it by charCodeAt, folding out-of-range (NaN or code >= 128) to -1 so the bitmath stays byte-identical to the old indexOf path on valid and malformed input alike. ~20-25% faster decoding a 2MB body; output byte-identical (differential test across valid, unpadded, '='-position, >127, non-alphabet and truncated inputs matches the old code exactly). No regression: obstacle course 33/33. --- crates/obscura-browser/src/page.rs | 17 ++++++--- crates/obscura-js/js/bootstrap.js | 59 +++++++++++++++++++++--------- crates/obscura-js/src/ops.rs | 39 ++++++++++---------- 3 files changed, 71 insertions(+), 44 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index a525994..2d18fbb 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -929,12 +929,17 @@ impl Page { .unwrap_or_default() .iter() .filter_map(|&nid| { - let node = dom.get_node(nid)?; - let rel = node.get_attribute("rel")?; - if rel.to_lowercase() != "stylesheet" { - return None; - } - node.get_attribute("href").map(|s| s.to_string()) + // Borrow the node instead of deep-cloning it; rel keywords are + // ASCII so eq_ignore_ascii_case matches to_lowercase() exactly + // without allocating a lowercased String. + dom.with_node(nid, |node| { + let rel = node.get_attribute("rel")?; + if !rel.eq_ignore_ascii_case("stylesheet") { + return None; + } + node.get_attribute("href").map(|s| s.to_string()) + }) + .flatten() }) .collect(); diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 484d36f..56eeb12 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -814,6 +814,10 @@ class Element extends Node { super(nid); this._style = _styleProxy(new CSSStyleDeclaration()); } + // Element wrappers always back a nodeType-1 node (_wrap/_wrapEl only build an + // Element for element nodes, and node ids are never freed-and-reused), so this + // is constant. Overrides Node's dynamic getter to drop one op per nodeType read. + get nodeType() { return 1; } get tagName() { return _domParse("tag_name", this._nid) || ""; } get localName() { // tagName is an op call and the tag never changes, so cache the lowercased @@ -956,18 +960,21 @@ class Element extends Node { get attributes() { const el = this; const names = _domParse("attribute_names", el._nid) || []; - const list = names.map((name) => ({ - name, - localName: name, - value: el.getAttribute(name) ?? "", - namespaceURI: null, - prefix: null, - specified: true, - ownerElement: el, - nodeName: name, - nodeValue: el.getAttribute(name) ?? "", - nodeType: 2, - })); + const list = names.map((name) => { + const v = el.getAttribute(name) ?? ""; + return { + name, + localName: name, + value: v, + namespaceURI: null, + prefix: null, + specified: true, + ownerElement: el, + nodeName: name, + nodeValue: v, + nodeType: 2, + }; + }); list.length = names.length; list.getNamedItem = (n) => names.includes(n) ? list[names.indexOf(n)] : null; list.setNamedItem = (a) => { if (a && a.name) el.setAttribute(a.name, a.value); return a; }; @@ -1573,11 +1580,13 @@ class Element extends Node { this.dispatchEvent(new Event('reset', { bubbles: true })); } get dataset() { + if (this._dataset) return this._dataset; const el = this; - return new Proxy({}, { + this._dataset = new Proxy({}, { get(_, k) { if(typeof k!=="string")return undefined; return el.getAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase()); }, set(_, k, v) { el.setAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase(), v); return true; }, }); + return this._dataset; } get offsetWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; } get offsetHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; } @@ -2500,18 +2509,32 @@ globalThis.pageXOffset = 0; globalThis.pageYOffset = 0; globalThis.__fetchInterceptEnabled = false; globalThis.__fetchInterceptCallback = null; // Set by CDP to handle paused requests +// charCode -> 6-bit value reverse table for base64 decode. -1 for any byte not +// in the standard alphabet, which mirrors String.indexOf's miss exactly, so the +// bitmath below stays byte-identical to the old indexOf path including on +// malformed input. Built once at module load. +const _B64_DECODE_TABLE = (function () { + const t = new Int16Array(128).fill(-1); + const a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + for (let i = 0; i < 64; i++) t[a.charCodeAt(i)] = i; + return t; +})(); + function _base64ToUint8Array(b64) { const clean = String(b64 || '').replace(/[\r\n\s]/g, ''); if (!clean) return new Uint8Array(); - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const T = _B64_DECODE_TABLE; const padding = clean.endsWith('==') ? 2 : (clean.endsWith('=') ? 1 : 0); const bytes = new Uint8Array((clean.length * 3 >> 2) - padding); let out = 0; for (let i = 0; i < clean.length; i += 4) { - const a = alphabet.indexOf(clean[i]); - const b = alphabet.indexOf(clean[i + 1]); - const c = clean[i + 2] === '=' ? 0 : alphabet.indexOf(clean[i + 2]); - const d = clean[i + 3] === '=' ? 0 : alphabet.indexOf(clean[i + 3]); + // charCodeAt avoids the per-char substring alloc; T[code] replaces the + // O(64) indexOf scan. Out-of-range (NaN or code >= 128) folds to -1, and + // `=== 61` is `=== '='`, so results match the old code exactly. + const ca = clean.charCodeAt(i); const a = ca < 128 ? T[ca] : -1; + const cb = clean.charCodeAt(i + 1); const b = cb < 128 ? T[cb] : -1; + const cc = clean.charCodeAt(i + 2); const c = cc === 61 ? 0 : (cc < 128 ? T[cc] : -1); + const cd = clean.charCodeAt(i + 3); const d = cd === 61 ? 0 : (cd < 128 ? T[cd] : -1); const n = (a << 18) | (b << 12) | (c << 6) | d; if (out < bytes.length) bytes[out++] = (n >> 16) & 0xff; if (out < bytes.length) bytes[out++] = (n >> 8) & 0xff; diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 4c94ffb..f3d292a 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -160,14 +160,14 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str } "node_type" => { let nid = arg1.parse::().unwrap_or(0); - dom.get_node(NodeId::new(nid)).map(|n| match &n.data { + dom.with_node(NodeId::new(nid), |n| match &n.data { NodeData::Document => "9", NodeData::Element { .. } => "1", NodeData::Text { .. } => "3", NodeData::Comment { .. } => "8", NodeData::Doctype { .. } => "10", NodeData::ProcessingInstruction { .. } => "7", }).unwrap_or("0").into() } "node_name" => { let nid = arg1.parse::().unwrap_or(0); - let name: String = dom.get_node(NodeId::new(nid)).map(|n| match &n.data { + let name: String = dom.with_node(NodeId::new(nid), |n| match &n.data { NodeData::Document => "#document".to_string(), NodeData::Element { name, .. } => name.local.as_ref().to_ascii_uppercase(), NodeData::Text { .. } => "#text".to_string(), NodeData::Comment { .. } => "#comment".to_string(), NodeData::Doctype { name, .. } => name.clone(), NodeData::ProcessingInstruction { target, .. } => target.clone(), @@ -180,11 +180,11 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str } "parent_node" | "first_child" | "last_child" | "next_sibling" | "prev_sibling" => { let nid = arg1.parse::().unwrap_or(0); - dom.get_node(NodeId::new(nid)).and_then(|n| match cmd.as_str() { + dom.with_node(NodeId::new(nid), |n| match cmd.as_str() { "parent_node" => n.parent, "first_child" => n.first_child, "last_child" => n.last_child, "next_sibling" => n.next_sibling, "prev_sibling" => n.prev_sibling, _ => None, - }).map(|id| id.index().to_string()).unwrap_or("-1".into()) + }).flatten().map(|id| id.index().to_string()).unwrap_or("-1".into()) } "child_nodes" => { let nid = arg1.parse::().unwrap_or(0); @@ -193,19 +193,18 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str } "tag_name" => { let nid = arg1.parse::().unwrap_or(0); - let name = dom.get_node(NodeId::new(nid)).and_then(|n| n.as_element().map(|name| name.local.as_ref().to_ascii_uppercase())).unwrap_or_default(); + let name = dom.with_node(NodeId::new(nid), |n| n.as_element().map(|name| name.local.as_ref().to_ascii_uppercase())).flatten().unwrap_or_default(); serde_json::to_string(&name).unwrap_or("\"\"".into()) } "get_attribute" => { let nid = arg1.parse::().unwrap_or(0); - let val = dom.get_node(NodeId::new(nid)).and_then(|n| n.get_attribute(&arg2).map(|s| s.to_string())); + let val = dom.with_node(NodeId::new(nid), |n| n.get_attribute(&arg2).map(|s| s.to_string())).flatten(); serde_json::to_string(&val).unwrap_or("null".into()) } "attribute_names" => { let nid = arg1.parse::().unwrap_or(0); let names: Vec = dom - .get_node(NodeId::new(nid)) - .map(|n| { + .with_node(NodeId::new(nid), |n| { n.attrs() .map(|a| a.iter().map(|x| x.name.local.as_ref().to_string()).collect()) .unwrap_or_default() @@ -218,7 +217,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str let node_id = NodeId::new(nid); if let Some((name, value)) = arg2.split_once('\0') { if name == "id" { - let old_id = dom.get_node(node_id).and_then(|n| n.get_attribute("id").map(|s| s.to_string())); + let old_id = dom.with_node(node_id, |n| n.get_attribute("id").map(|s| s.to_string())).flatten(); dom.with_node_mut(node_id, |n| n.set_attribute(name, value.to_string())); dom.update_id_index(node_id, old_id.as_deref(), Some(value)); } else { @@ -321,26 +320,26 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str } "pi_target" => { let nid = arg1.parse::().unwrap_or(0); - let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data { + let val = dom.with_node(NodeId::new(nid), |n| match &n.data { NodeData::ProcessingInstruction { target, .. } => Some(target.clone()), _ => None, - }).unwrap_or_default(); + }).flatten().unwrap_or_default(); serde_json::to_string(&val).unwrap_or("\"\"".into()) } "doctype_name" => { let nid = arg1.parse::().unwrap_or(0); - let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data { + let val = dom.with_node(NodeId::new(nid), |n| match &n.data { NodeData::Doctype { name, .. } => Some(name.clone()), _ => None, - }).unwrap_or_default(); + }).flatten().unwrap_or_default(); serde_json::to_string(&val).unwrap_or("\"\"".into()) } "doctype_public_id" => { let nid = arg1.parse::().unwrap_or(0); - let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data { + let val = dom.with_node(NodeId::new(nid), |n| match &n.data { NodeData::Doctype { public_id, .. } => Some(public_id.clone()), _ => None, - }).unwrap_or_default(); + }).flatten().unwrap_or_default(); serde_json::to_string(&val).unwrap_or("\"\"".into()) } "element_children" => { @@ -352,7 +351,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str } "has_child_nodes" => { let nid = arg1.parse::().unwrap_or(0); - dom.get_node(NodeId::new(nid)).map(|n| n.first_child.is_some()).unwrap_or(false).to_string() + dom.with_node(NodeId::new(nid), |n| n.first_child.is_some()).unwrap_or(false).to_string() } "contains" => { let nid = arg1.parse::().unwrap_or(0); @@ -377,7 +376,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str // walk of parentNode ops from JS. "node_root" => { let mut cur = NodeId::new(arg1.parse::().unwrap_or(0)); - while let Some(p) = dom.get_node(cur).and_then(|x| x.parent) { + while let Some(p) = dom.with_node(cur, |x| x.parent).flatten() { cur = p; } cur.index().to_string() @@ -389,10 +388,10 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str /// Index of `n` among its parent's children (0-based). fn node_child_index(dom: &DomTree, n: NodeId) -> usize { let mut i = 0usize; - let mut cur = dom.get_node(n).and_then(|x| x.prev_sibling); + let mut cur = dom.with_node(n, |x| x.prev_sibling).flatten(); while let Some(p) = cur { i += 1; - cur = dom.get_node(p).and_then(|x| x.prev_sibling); + cur = dom.with_node(p, |x| x.prev_sibling).flatten(); } i } @@ -401,7 +400,7 @@ fn node_child_index(dom: &DomTree, n: NodeId) -> usize { fn node_ancestors_root_first(dom: &DomTree, n: NodeId) -> Vec { let mut v = vec![n]; let mut cur = n; - while let Some(p) = dom.get_node(cur).and_then(|x| x.parent) { + while let Some(p) = dom.with_node(cur, |x| x.parent).flatten() { v.push(p); cur = p; }