From 111150a4f8046d22996955bbedc2ca2d005cba3c Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Sun, 3 May 2026 17:19:17 +0200 Subject: [PATCH] fix(js): add elementFromPoint / elementsFromPoint stubs (#63) (#75) document.elementFromPoint(x, y) was undefined, which caused Google Publisher Tag, viewability libs and any code doing hit-testing to throw on bootstrap and trigger retry loops in the surrounding framework. This is one of the throwing APIs feeding into the wedge described in #62. Without a layout engine we can't return the actual topmost element, but a non-throwing stub is strictly better than "undefined" for the headless-scraping use case: - coords inside the viewport return document.body (or document.documentElement as a fallback) so callers that just want a hit target stop throwing - coords outside the viewport (negative, > innerWidth/innerHeight, NaN/Infinity) return null per spec - elementsFromPoint mirrors the same shape but returns a one- or zero-length array - ShadowRoot.prototype gets the same treatment for completeness Five new unit tests in obscura-js cover function presence, in-viewport hit, out-of-viewport coords, non-numeric input, and the array shape. Generated by Ora Studio Vibe coded by ousamabenyounes Co-authored-by: Ora Studio --- crates/obscura-js/js/bootstrap.js | 30 +++++++++++++++++++++ crates/obscura-js/src/runtime.rs | 45 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 20d5e2d..0dd5936 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -3080,6 +3080,36 @@ if (typeof Document !== 'undefined' && !Document.prototype.importNode) { Document.prototype.importNode = function(node, deep) { return node?.cloneNode(!!deep) || null; }; } +// Document.elementFromPoint / elementsFromPoint — no layout engine, so this is a stub: +// in-viewport coords return (or as fallback), out-of-viewport returns null. +// Wrong-but-non-throwing beats "undefined", which traps ad/analytics bootstraps in retry loops +// (see issue #63). +if (typeof Document !== 'undefined' && !Document.prototype.elementFromPoint) { + Document.prototype.elementFromPoint = function(x, y) { + if (typeof x !== 'number' || typeof y !== 'number' || !isFinite(x) || !isFinite(y)) { + return null; + } + var w = (typeof window !== 'undefined' && window.innerWidth) || 0; + var h = (typeof window !== 'undefined' && window.innerHeight) || 0; + if (x < 0 || y < 0 || x > w || y > h) { + return null; + } + return this.body || this.documentElement || null; + }; + Document.prototype.elementsFromPoint = function(x, y) { + var el = this.elementFromPoint(x, y); + return el ? [el] : []; + }; +} +if (typeof ShadowRoot !== 'undefined' && !ShadowRoot.prototype.elementFromPoint) { + ShadowRoot.prototype.elementFromPoint = function(x, y) { + return Document.prototype.elementFromPoint.call(globalThis.document || this, x, y); + }; + ShadowRoot.prototype.elementsFromPoint = function(x, y) { + return Document.prototype.elementsFromPoint.call(globalThis.document || this, x, y); + }; +} + globalThis.__obscura_init = function() { _fpSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF >>> 0); _fpCache = null; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 4aa62e9..32f98ce 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1480,4 +1480,49 @@ mod tests { assert!(html.contains("

Test

")); } + #[test] + fn test_element_from_point_is_function() { + let mut rt = setup_runtime(""); + let kind = rt.evaluate("typeof document.elementFromPoint").unwrap(); + assert_eq!(kind, serde_json::json!("function")); + let kind2 = rt.evaluate("typeof document.elementsFromPoint").unwrap(); + assert_eq!(kind2, serde_json::json!("function")); + } + + #[test] + fn test_element_from_point_in_viewport_returns_body() { + let mut rt = setup_runtime("

Hi

"); + let tag = rt.evaluate("document.elementFromPoint(10, 10)?.tagName").unwrap(); + assert_eq!(tag, serde_json::json!("BODY")); + } + + #[test] + fn test_element_from_point_out_of_viewport_returns_null() { + let mut rt = setup_runtime(""); + let neg_x = rt.evaluate("document.elementFromPoint(-1, 10)").unwrap(); + assert_eq!(neg_x, serde_json::Value::Null); + let neg_y = rt.evaluate("document.elementFromPoint(10, -1)").unwrap(); + assert_eq!(neg_y, serde_json::Value::Null); + let huge = rt.evaluate("document.elementFromPoint(99999, 99999)").unwrap(); + assert_eq!(huge, serde_json::Value::Null); + } + + #[test] + fn test_elements_from_point_returns_array() { + let mut rt = setup_runtime(""); + let len_in = rt.evaluate("document.elementsFromPoint(10, 10).length").unwrap(); + assert_eq!(len_in.as_f64().unwrap() as i64, 1); + let len_out = rt.evaluate("document.elementsFromPoint(-1, -1).length").unwrap(); + assert_eq!(len_out.as_f64().unwrap() as i64, 0); + } + + #[test] + fn test_element_from_point_non_numeric_returns_null() { + let mut rt = setup_runtime(""); + let nan = rt.evaluate("document.elementFromPoint(NaN, 10)").unwrap(); + assert_eq!(nan, serde_json::Value::Null); + let inf = rt.evaluate("document.elementFromPoint(Infinity, 10)").unwrap(); + assert_eq!(inf, serde_json::Value::Null); + } + }