diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 0581fda..9eb5b45 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -81,7 +81,19 @@ globalThis.dispatchEvent = function(event) { return !event.defaultPrevented; }; -const _dom = (cmd, a1, a2) => Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? "")); +let _domMutationEpoch = 0; +const _DOM_MUTATION_COMMANDS = new Set([ + "append_child", "insert_before", "remove_child", + "set_attribute", "remove_attribute", + "set_text_content", "set_inner_html", "set_inner_html_context", + "create_element", "create_text_node", "create_comment_node", + "create_document_fragment", "clone_node", +]); +const _dom = (cmd, a1, a2) => { + const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? "")); + if (_DOM_MUTATION_COMMANDS.has(cmd)) _domMutationEpoch++; + return result; +}; const _nativeFns = new Set(); // Exact toString override for members whose native form is not just @@ -2556,6 +2568,23 @@ class Element extends Node { return this.getAttribute("href") || ""; } set href(v) { this.setAttribute("href", v); } + // HTMLHyperlinkElementUtils / HTMLAnchorElement reflected content + // attributes. Real-world locale, routing, and analytics code commonly + // enumerates `[hreflang]` links and reads the IDL property rather than + // getAttribute(); leaving it undefined aborts the entire component even + // though the attribute is present in the DOM. + get hreflang() { return this.getAttribute("hreflang") || ""; } + set hreflang(v) { this.setAttribute("hreflang", v); } + get rel() { return this.getAttribute("rel") || ""; } + set rel(v) { this.setAttribute("rel", v); } + get target() { return this.getAttribute("target") || ""; } + set target(v) { this.setAttribute("target", v); } + get download() { return this.getAttribute("download") || ""; } + set download(v) { this.setAttribute("download", v); } + get ping() { return this.getAttribute("ping") || ""; } + set ping(v) { this.setAttribute("ping", v); } + get referrerPolicy() { return this.getAttribute("referrerpolicy") || ""; } + set referrerPolicy(v) { this.setAttribute("referrerpolicy", v); } // HTMLHyperlinkElementUtils URL-decomposition members, live on /. get protocol() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.protocol : ''; } set protocol(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'protocol', v); } @@ -5863,12 +5892,21 @@ globalThis.getComputedStyle = (el) => { // cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a // dozen properties must not trigger a dozen layout/native crossings. let rendered = null; - if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) { - try { - const raw = Deno.core.ops.op_computed_style(String(el._nid | 0)); - rendered = raw ? JSON.parse(raw) : null; - } catch (e) {} - } + let renderedEpoch = -1; + let computedNames = []; + const refreshRendered = () => { + if (renderedEpoch === _domMutationEpoch) return; + renderedEpoch = _domMutationEpoch; + rendered = null; + if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) { + try { + const raw = Deno.core.ops.op_computed_style(String(el._nid | 0)); + rendered = raw ? JSON.parse(raw) : null; + } catch (e) {} + } + computedNames = rendered ? Object.keys(rendered) : []; + }; + refreshRendered(); // React virtualization libraries (react-window, tanstack-virtual, // react-virtuoso) all compute container dimensions via getComputedStyle. // The defaults table previously returned `auto` for width/height and @@ -5924,6 +5962,7 @@ globalThis.getComputedStyle = (el) => { const lookup = (rawProp) => { if (typeof rawProp !== 'string') return ''; + refreshRendered(); const kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase(); if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab)) return rendered[kebab]; @@ -5944,8 +5983,14 @@ globalThis.getComputedStyle = (el) => { if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined; if (prop === 'getPropertyValue') return (name) => lookup(name); if (prop === 'getPropertyPriority') return () => ''; - if (prop === 'item') return (i) => ''; - if (prop === 'length') return 0; + if (prop === 'item') return (i) => { + refreshRendered(); + return computedNames[i | 0] || ''; + }; + if (prop === 'length') { + refreshRendered(); + return computedNames.length; + } if (prop === 'cssText') return ''; if (prop === 'parentRule') return null; // CSSStyleDeclaration's `has` trap intentionally reports every known diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 97197b7..f18177d 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -2473,10 +2473,23 @@ fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String { let nid: u32 = nid_str.parse().unwrap_or(0); let nid = obscura_dom::tree::NodeId::new(nid); let mut gs = shared.borrow_mut(); - ensure_prepared_render(&mut gs) - .and_then(|prepared| prepared.computed_style(nid)) - .and_then(|snapshot| serde_json::to_string(&snapshot).ok()) - .unwrap_or_default() + let Some(prepared) = ensure_prepared_render(&mut gs) else { + return String::new(); + }; + let Some(snapshot) = prepared.computed_style(nid) else { + return String::new(); + }; + let custom = prepared + .computed_custom_properties(nid) + .unwrap_or_default(); + let mut object = serde_json::Map::with_capacity(snapshot.len() + custom.len()); + for (name, value) in snapshot { + object.insert(name.to_string(), serde_json::Value::String(value)); + } + for (name, value) in custom { + object.insert(name, serde_json::Value::String(value)); + } + serde_json::Value::Object(object).to_string() } /// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 1607826..770a655 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2168,6 +2168,52 @@ mod tests { ); } + #[test] + fn hyperlink_content_attributes_reflect_through_the_idl_surface() { + let mut rt = setup_runtime( + r#" + English + "#, + ); + let result = rt + .evaluate( + r#" + const link = document.getElementById("locale"); + const initial = [ + link.hreflang, link.rel, link.target, link.download, + link.ping, link.referrerPolicy, + link.hreflang.split("-")[1] + ]; + link.hreflang = "de-DE"; + link.referrerPolicy = "origin"; + return [ + initial, + link.getAttribute("hreflang"), + link.getAttribute("referrerpolicy") + ]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([ + [ + "en-US", + "alternate", + "_blank", + "guide.pdf", + "/audit", + "no-referrer", + "US" + ], + "de-DE", + "origin" + ]) + ); + } + #[cfg(feature = "render")] #[test] fn computed_style_uses_renderer_stylesheet_cascade_and_invalidates() { @@ -2239,6 +2285,78 @@ mod tests { ); } + #[cfg(feature = "render")] + #[test] + fn computed_style_exposes_cascaded_custom_properties_and_invalidates() { + let dom = parse_html( + r#"
+ + "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(400.0, 200.0); + rt.run_page_init(); + + assert_eq!( + rt.evaluate( + r#" + const nav = document.getElementById("nav"); + const child = document.getElementById("child"); + const navStyle = getComputedStyle(nav); + const childStyle = getComputedStyle(child); + let enumeratesBase = false; + for (let i = 0; i < navStyle.length; i++) { + if (navStyle.item(i) === "--r-globalnav-font-size") + enumeratesBase = true; + } + return [ + navStyle.fontSize, + navStyle.getPropertyValue("--r-globalnav-font-size"), + parseInt(navStyle.fontSize) / + parseInt(navStyle.getPropertyValue("--r-globalnav-font-size")), + navStyle.getPropertyValue("--inherited-space"), + navStyle.getPropertyValue("--derived-space"), + navStyle.getPropertyValue("--local-scale"), + childStyle.getPropertyValue("--inherited-space"), + childStyle.getPropertyValue("--local-scale"), + enumeratesBase + ]; + "#, + ) + .unwrap(), + serde_json::json!([ + "17px", "17px", 1, + "17px", "17px", "1.25", "17px", "1.25", true + ]) + ); + + assert_eq!( + rt.evaluate( + r#" + const nav = document.getElementById("nav"); + const computed = getComputedStyle(nav); + nav.style.setProperty("--inherited-space", "23px"); + nav.style.fontSize = "19px"; + return [ + computed.fontSize, + computed.getPropertyValue("--inherited-space"), + computed.getPropertyValue("--derived-space") + ]; + "#, + ) + .unwrap(), + serde_json::json!(["19px", "23px", "17px"]) + ); + } + #[tokio::test(flavor = "current_thread")] async fn idle_event_loop_flushes_resolved_promise_continuations() { let mut rt = setup_runtime("