Preserve script execution state across DOM moves

This commit is contained in:
SGavrl committed 2026-08-03 11:49:08 +00:00
1 parent 6dec05f171
commit 41a8e1c1a6
4 files changed
+626 -116

No files matched your search

+131 -4
View File
@@ -197,6 +197,10 @@ pub struct Page {
// contract. Includes `Runtime.addBinding` shims so puppeteer's
// `exposeFunction` bindings exist before inline `<script>` tags execute.
preload_scripts: Vec<String>,
/// Document-owned HTML script preparation flags saved while the V8 realm
/// is suspended for CDP/MCP tab switching. These are restored only when
/// the same surviving DomTree is resumed; navigation clears them.
suspended_started_script_ids: Vec<u32>,
/// Passive on_request/on_response callbacks, scoped to this page (issue
/// #408): they fire only for requests this page drives and die with it.
/// Arc because the JS runtime state holds a second handle for fetch()/XHR.
@@ -677,6 +681,7 @@ impl Page {
blocked_url_patterns: Vec::new(),
intercept_tx: None,
preload_scripts: Vec::new(),
suspended_started_script_ids: Vec::new(),
callbacks: Arc::new(CallbackRegistry::new()),
#[cfg(feature = "stealth")]
stealth_client,
@@ -726,6 +731,11 @@ impl Page {
.await
}
fn init_js(&mut self) {
// init_js is also the new-document path. Only resume_js explicitly
// takes these IDs out before entering here and restores them after the
// same DomTree is installed; a navigation must never inherit IDs from
// a suspended prior document whose allocator may reuse them.
self.suspended_started_script_ids.clear();
// Drop any existing runtime so the JS realm starts clean on
// every navigation. The old code reused the V8 isolate and
// only re-bound `globalThis.document`, leaving window.onload,
@@ -2332,10 +2342,16 @@ impl Page {
}
pub fn suspend_js(&mut self) {
if let Some(js) = &self.js {
if let Some(dom) = js.take_dom() {
self.dom = Some(dom);
}
let Some(js) = &self.js else {
return;
};
let started_script_ids = js.started_script_ids();
let dom = js.take_dom();
if let Some(dom) = dom {
self.dom = Some(dom);
self.suspended_started_script_ids = started_script_ids;
} else {
self.suspended_started_script_ids.clear();
}
self.js = None;
}
@@ -2344,7 +2360,11 @@ impl Page {
if self.js.is_some() {
return;
}
let started_script_ids = std::mem::take(&mut self.suspended_started_script_ids);
self.init_js();
if let Some(js) = &self.js {
js.restore_started_script_ids(&started_script_ids);
}
}
pub fn has_js(&self) -> bool {
@@ -2553,6 +2573,113 @@ mod tests {
assert!(!script_response_is_executable(500));
}
#[tokio::test(flavor = "current_thread")]
async fn suspend_resume_preserves_document_script_start_state() {
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"script-state-suspend".to_string(), None, false, None, None, true,
));
let mut page = super::Page::new("script-state-suspend".to_string(), context);
page.url = Some(url::Url::parse("http://example.com/suspend.html").unwrap());
page.dom = Some(parse_html(
r#"<html><head></head><body data-parser-runs="0" data-dynamic-runs="0" data-inert-runs="0">
<script id="parser">
document.body.setAttribute("data-parser-runs", String(Number(document.body.getAttribute("data-parser-runs")) + 1));
</script>
</body></html>"#,
));
page.init_js();
page.execute_scripts().await;
let before = page
.js
.as_mut()
.unwrap()
.evaluate(
r#"
var scriptStateSetup = true;
const dynamic = document.createElement("script");
dynamic.id = "dynamic";
dynamic.textContent =
'document.body.setAttribute("data-dynamic-runs", String(Number(document.body.getAttribute("data-dynamic-runs")) + 1))';
document.body.appendChild(dynamic);
const holder = document.createElement("div");
holder.innerHTML =
'<script id="inert">document.body.setAttribute("data-inert-runs", String(Number(document.body.getAttribute("data-inert-runs")) + 1))<\/script>';
document.body.appendChild(holder.firstChild);
return [
document.body.getAttribute("data-parser-runs"),
document.body.getAttribute("data-dynamic-runs"),
document.body.getAttribute("data-inert-runs")
];
"#,
)
.unwrap();
assert_eq!(before, serde_json::json!(["1", "1", "0"]));
page.suspend_js();
page.suspend_js();
page.resume_js();
let after = page
.js
.as_mut()
.unwrap()
.evaluate(
r#"
var scriptStateCheck = true;
for (const id of ["parser", "dynamic", "inert"]) {
const script = document.getElementById(id);
document.head.appendChild(script);
document.body.appendChild(script.cloneNode(true));
}
return [
document.body.getAttribute("data-parser-runs"),
document.body.getAttribute("data-dynamic-runs"),
document.body.getAttribute("data-inert-runs")
];
"#,
)
.unwrap();
assert_eq!(after, serde_json::json!(["1", "1", "0"]));
}
#[test]
fn new_document_does_not_inherit_suspended_script_ids() {
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"script-state-navigation".to_string(), None, false, None, None, true,
));
let mut page = super::Page::new("script-state-navigation".to_string(), context);
page.url = Some(url::Url::parse("http://example.com/old.html").unwrap());
page.dom = Some(parse_html(
"<html><head></head><body><script id=old></script></body></html>",
));
page.init_js();
page.js
.as_mut()
.unwrap()
.evaluate(
"var setup = true; const old = document.getElementById('old'); globalThis.__markParserScripts([old._nid]); return old._nid;",
)
.unwrap();
page.suspend_js();
page.url = Some(url::Url::parse("http://example.com/new.html").unwrap());
page.dom = Some(parse_html(
"<html><head></head><body data-fresh-runs=0><script id=fresh>document.body.setAttribute('data-fresh-runs', '1')</script></body></html>",
));
page.init_js();
let result = page
.js
.as_mut()
.unwrap()
.evaluate(
"var check = true; document.head.appendChild(document.getElementById('fresh')); return document.body.getAttribute('data-fresh-runs');",
)
.unwrap();
assert_eq!(result, serde_json::json!("1"));
}
fn import_map_test_page(name: &str, base: &str, html: &str) -> super::Page {
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
name.to_string(), None, false, None, None, true,
+124 -106
View File
@@ -86,6 +86,7 @@ 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",
"set_fragment_html_executable",
"create_element", "create_text_node", "create_comment_node",
"create_document_fragment", "clone_node",
]);
@@ -239,13 +240,11 @@ function _decodeDataScriptUrl(url) {
}
return new TextDecoder().decode(new Uint8Array(bytes));
}
// A script element executes at most once. Parser-discovered scripts are marked
// before page execution starts, and dynamically inserted scripts are marked
// when they are queued. Framework hydration may move existing <script> nodes;
// moving one must not execute its source or inline body a second time.
let __startedScriptNids = new Set();
// A script element executes at most once. The authoritative flag lives in
// native per-document state so it survives wrapper churn, fragment parsing,
// moves, and cloneNode().
globalThis.__markParserScripts = function(nids) {
for (const nid of nids || []) __startedScriptNids.add(+nid);
for (const nid of nids || []) Deno.core.ops.op_script_mark_started(+nid);
};
async function __processDynScriptQueue() {
if (__dynScriptBusy) return;
@@ -1054,6 +1053,111 @@ function _eventTargetDispatch(target, event) {
// browser custom-element implementations.
const _customElementConstructionStack = [];
function __prepareInsertedScript(script) {
if (!Deno.core.ops.op_script_try_start(script._nid)) return;
const scriptType = (script.getAttribute('type') || '').trim().toLowerCase();
const isModule = scriptType === 'module';
const isImportMap = scriptType === 'importmap';
if (isImportMap) {
const src = script.getAttribute('src');
let error = '';
if (src) {
error = 'External import maps are not supported';
} else {
const base = script.baseURI
|| globalThis.location?.href
|| 'about:blank';
try {
error = Deno.core.ops.op_add_import_map(script.textContent || '', base) || '';
} catch (e) {
error = e && e.message ? e.message : String(e);
}
}
if (error) {
console.error('Import map error:', error);
queueMicrotask(() => {
try { script.dispatchEvent(new Event('error')); } catch (_) {}
});
}
return;
}
if (scriptType && !isModule && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') {
return;
}
const src = script.getAttribute('src');
const code = src ? "" : script.textContent;
if (!src && !code) return;
const prevNid = globalThis.__currentScriptNid;
if (src) {
let baseHref;
try {
const baseEl = globalThis.document?.querySelector('base[href]');
baseHref = baseEl ? baseEl.getAttribute('href') : null;
} catch(e) { baseHref = null; }
const docUrl = globalThis.location?.href || 'http://localhost/';
let baseUrl;
try { baseUrl = baseHref ? new URL(baseHref, docUrl).href : docUrl; }
catch(e) { baseUrl = docUrl; }
let fullUrl;
try {
fullUrl = src.startsWith('http') || src.startsWith('data:')
? src
: new URL(src, baseUrl).href;
} catch(e) {
console.error('Dynamic script URL resolve failed (' + src + '):', e.message);
fullUrl = src;
}
const pageOrigin = (function() { try { return new URL(baseUrl).origin; } catch(e) { return ""; } })();
__dynScriptQueue.push({
url: fullUrl,
isModule,
nid: script._nid,
prevNid,
pageOrigin,
dispatchEvent: (ev) => { try { script.dispatchEvent(ev); } catch(e) {} },
});
__processDynScriptQueue();
} else if (isModule) {
const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)));
__dynScriptQueue.push({
url: dataUrl,
isModule: true,
nid: script._nid,
prevNid,
pageOrigin: "",
dispatchEvent: (ev) => { try { script.dispatchEvent(ev); } catch(e) {} },
});
__processDynScriptQueue();
} else {
globalThis.__currentScriptNid = script._nid;
try { (0, eval)(code); }
catch(e) { console.error('Dynamic inline script error:', e.message); }
finally { globalThis.__currentScriptNid = prevNid || 0; }
}
}
function __prepareInsertedSubtree(root) {
// HTML's script preparation algorithm leaves a disconnected script
// unstarted. When an ancestor is later connected, insertion steps visit
// every script in that subtree in tree order.
if (!root || !root.isConnected) return;
const scripts = [];
const seen = new Set();
if (root.nodeType === 1 && root.tagName === 'SCRIPT') {
scripts.push(root);
seen.add(root._nid);
}
const ids = _domParse("query_selector_all_scoped", root._nid, "script") || [];
for (const nid of ids) {
const script = _wrapEl(+nid);
if (script && !seen.has(script._nid)) {
scripts.push(script);
seen.add(script._nid);
}
}
for (const script of scripts) __prepareInsertedScript(script);
}
class Node {
static ELEMENT_NODE = 1;
static ATTRIBUTE_NODE = 2;
@@ -1157,99 +1261,7 @@ class Node {
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') {
if (__startedScriptNids.has(c._nid)) return c;
const scriptType = (c.getAttribute('type') || '').trim().toLowerCase();
const isModule = scriptType === 'module';
const isImportMap = scriptType === 'importmap';
if (isImportMap) {
__startedScriptNids.add(c._nid);
const src = c.getAttribute('src');
let error = '';
if (src) {
error = 'External import maps are not supported';
} else {
const base = c.baseURI
|| globalThis.location?.href
|| 'about:blank';
try {
error = Deno.core.ops.op_add_import_map(c.textContent || '', base) || '';
} catch (e) {
error = e && e.message ? e.message : String(e);
}
}
if (error) {
console.error('Import map error:', error);
queueMicrotask(() => {
try { c.dispatchEvent(new Event('error')); } catch (_) {}
});
}
return c;
}
if (scriptType && !isModule && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') {
return c;
}
const src = c.getAttribute('src');
const code = src ? "" : c.textContent;
if (!src && !code) return c;
__startedScriptNids.add(c._nid);
const prevNid = globalThis.__currentScriptNid;
if (src) {
// Resolve against <base href> when present, else the document URL.
// The base href is resolved to an absolute URL first: a bare path like
// <base href="/"> (the common Angular form) is not a valid URL base on
// its own and would otherwise throw. Both the base and the final
// resolution are guarded so a bad value can never escape appendChild.
let baseHref;
try {
const baseEl = globalThis.document?.querySelector('base[href]');
baseHref = baseEl ? baseEl.getAttribute('href') : null;
} catch(e) { baseHref = null; }
const docUrl = globalThis.location?.href || 'http://localhost/';
let baseUrl;
try { baseUrl = baseHref ? new URL(baseHref, docUrl).href : docUrl; }
catch(e) { baseUrl = docUrl; }
let fullUrl;
try {
fullUrl = src.startsWith('http') || src.startsWith('data:')
? src
: new URL(src, baseUrl).href;
} catch(e) {
console.error('Dynamic script URL resolve failed (' + src + '):', e.message);
fullUrl = src;
}
const pageOrigin = (function() { try { return new URL(baseUrl).origin; } catch(e) { return ""; } })();
// Enqueue — serialized via __processDynScriptQueue to prevent
// concurrent import() calls from triggering deno_core RefCell panic.
__dynScriptQueue.push({
url: fullUrl,
isModule,
nid: c._nid,
prevNid,
pageOrigin,
dispatchEvent: (ev) => { try { c.dispatchEvent(ev); } catch(e) {} },
});
__processDynScriptQueue();
} else {
if (isModule) {
const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)));
__dynScriptQueue.push({
url: dataUrl,
isModule: true,
nid: c._nid,
prevNid,
pageOrigin: "",
dispatchEvent: (ev) => { try { c.dispatchEvent(ev); } catch(e) {} },
});
__processDynScriptQueue();
} else {
globalThis.__currentScriptNid = c._nid;
try { (0, eval)(code); }
catch(e) { console.error('Dynamic inline script error:', e.message); }
finally { globalThis.__currentScriptNid = prevNid || 0; }
}
}
}
__prepareInsertedSubtree(c);
if (c instanceof Element && c.tagName === 'LINK') {
_loadLinkedStylesheet(c);
}
@@ -1279,6 +1291,7 @@ class Node {
if (newChild._shadowParent) newChild._shadowParent.removeChild(newChild);
_dom("insert_before", newChild._nid, oldChild._nid);
_dom("remove_child", oldChild._nid);
__prepareInsertedSubtree(newChild);
return oldChild;
}
insertBefore(n, ref) {
@@ -1291,6 +1304,7 @@ class Node {
}
if (n._shadowParent) n._shadowParent.removeChild(n);
_dom("insert_before", n._nid, ref._nid);
__prepareInsertedSubtree(n);
return n;
}
contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; }
@@ -3894,9 +3908,9 @@ class Document extends Node {
if (!html) return;
var body = this.body;
if (!body) return;
var temp = this.createElement('div');
temp.innerHTML = html;
var children = temp.childNodes;
var temp = this.createDocumentFragment();
_dom("set_fragment_html_executable", temp._nid, "body\0" + html);
var children = Array.from(temp.childNodes);
for (var i = 0; i < children.length; i++) {
body.appendChild(children[i]);
}
@@ -3955,8 +3969,9 @@ class DocumentFragment extends Node {
return null;
}
cloneNode(deep) {
const frag = document.createDocumentFragment();
if (deep) frag.innerHTML = this.innerHTML;
const nid = +_dom("clone_node", this._nid, deep ? "true" : "false");
const frag = new DocumentFragment(nid);
_cache.set(nid, frag);
return frag;
}
}
@@ -8342,7 +8357,11 @@ globalThis.Range = class Range {
const node = this._sc;
const ownerDoc = (node && node.ownerDocument) || globalThis.document;
const frag = ownerDoc.createDocumentFragment();
frag.innerHTML = String(html);
let context = node;
if (context && context.nodeType !== 1) context = context.parentElement;
let contextName = (context && context.localName) || 'body';
if (contextName === 'html') contextName = 'body';
_dom("set_fragment_html_executable", frag._nid, contextName + "\0" + String(html));
return frag;
}
toString() {
@@ -10922,7 +10941,6 @@ if (typeof ShadowRoot !== 'undefined' && !ShadowRoot.prototype.elementFromPoint)
globalThis.__obscura_init = function() {
_fpSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF >>> 0);
_fpCache = null;
__startedScriptNids.clear();
// A real navigation just completed (this runs after set_url), so drop any
// URL a location setter previewed synchronously and let document_url drive
// location.href again, including any redirect target.
+165 -4
View File
@@ -1,5 +1,5 @@
use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
use std::sync::Arc;
@@ -128,6 +128,10 @@ pub struct ObscuraState {
/// Window-global import-map state shared by parser-discovered scripts,
/// dynamically inserted import maps, and the module loader.
pub(crate) import_map: Rc<RefCell<ImportMap>>,
/// HTML's per-script "already started" flag. This is native page state,
/// rather than wrapper state, because it must survive moves and clones and
/// because fragment parsing can create nodes before a JS wrapper exists.
pub(crate) already_started_scripts: RefCell<HashSet<NodeId>>,
}
impl ObscuraState {
@@ -164,10 +168,94 @@ impl ObscuraState {
#[cfg(feature = "render")]
scroll_offset: (0.0, 0.0),
import_map: Rc::new(RefCell::new(ImportMap::default())),
already_started_scripts: RefCell::new(HashSet::new()),
}
}
}
pub(crate) fn node_is_script(dom: &DomTree, node_id: NodeId) -> bool {
dom.with_node(node_id, |node| {
node.as_element()
.map(|name| name.local.as_ref().eq_ignore_ascii_case("script"))
.unwrap_or(false)
})
.unwrap_or(false)
}
fn script_nodes_including_template_contents(dom: &DomTree, root: NodeId) -> Vec<NodeId> {
let mut scripts = Vec::new();
let mut stack = vec![root];
while let Some(node_id) = stack.pop() {
if node_is_script(dom, node_id) {
scripts.push(node_id);
}
let template_contents = dom
.with_node(node_id, |node| match &node.data {
NodeData::Element { template_contents, .. } => *template_contents,
_ => None,
})
.flatten();
if let Some(contents) = template_contents {
stack.push(contents);
}
let children = dom.children(node_id);
for child in children.into_iter().rev() {
stack.push(child);
}
}
scripts
}
pub(crate) fn mark_script_subtree_started(state: &ObscuraState, root: NodeId) {
let Some(dom) = state.dom.as_ref() else {
return;
};
let scripts = script_nodes_including_template_contents(dom, root);
state.already_started_scripts.borrow_mut().extend(scripts);
}
fn propagate_script_start_state(
dom: &DomTree,
source_root: NodeId,
cloned_root: NodeId,
started: &RefCell<HashSet<NodeId>>,
) {
let mut pairs = vec![(source_root, cloned_root)];
let mut additions = Vec::new();
let current = started.borrow();
while let Some((source, cloned)) = pairs.pop() {
if current.contains(&source) {
additions.push(cloned);
}
let source_template = dom
.with_node(source, |node| match &node.data {
NodeData::Element { template_contents, .. } => *template_contents,
_ => None,
})
.flatten();
let cloned_template = dom
.with_node(cloned, |node| match &node.data {
NodeData::Element { template_contents, .. } => *template_contents,
_ => None,
})
.flatten();
if let (Some(source_contents), Some(cloned_contents)) =
(source_template, cloned_template)
{
pairs.push((source_contents, cloned_contents));
}
let source_children = dom.children(source);
let cloned_children = dom.children(cloned);
for pair in source_children.into_iter().zip(cloned_children).rev() {
pairs.push(pair);
}
}
drop(current);
started.borrow_mut().extend(additions);
}
fn response_body_entry_limit() -> usize {
std::env::var("OBSCURA_NETWORK_BODY_BUFFER_ENTRIES")
.ok()
@@ -184,6 +272,38 @@ fn response_body_byte_limit() -> usize {
pub type SharedState = Rc<RefCell<ObscuraState>>;
#[op2(fast)]
fn op_script_mark_started(state: &OpState, nid: u32) -> bool {
let shared = state.borrow::<SharedState>().clone();
let state = shared.borrow();
let Some(dom) = state.dom.as_ref() else {
return false;
};
let node_id = NodeId::new(nid);
if !node_is_script(dom, node_id) {
return false;
}
state.already_started_scripts.borrow_mut().insert(node_id);
true
}
/// Atomically claim an executable script. A false result means the node was
/// created inert by an HTML-string API or has already been prepared once.
#[op2(fast)]
fn op_script_try_start(state: &OpState, nid: u32) -> bool {
let shared = state.borrow::<SharedState>().clone();
let state = shared.borrow();
let Some(dom) = state.dom.as_ref() else {
return false;
};
let node_id = NodeId::new(nid);
if !node_is_script(dom, node_id) {
return false;
}
let newly_started = state.already_started_scripts.borrow_mut().insert(node_id);
newly_started
}
#[op2]
#[string]
fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[string] arg2: String) -> String {
@@ -491,6 +611,9 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
};
let import_root = fragment.fragment_root();
dom.import_children_from(target, &fragment, import_root);
for child in dom.children(target) {
mark_script_subtree_started(&gs, child);
}
}
"true".into()
}
@@ -515,6 +638,32 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
let fragment = obscura_dom::parse_fragment_with_context(html, context_name);
let import_root = fragment.fragment_root();
dom.import_children_from(target, &fragment, import_root);
for child in dom.children(target) {
mark_script_subtree_started(&gs, child);
}
}
"true".into()
}
// Range.createContextualFragment has a deliberately different script
// policy from innerHTML: scripts remain eligible and are prepared when
// the returned fragment is inserted into a connected document.
"set_fragment_html_executable" => {
let nid = match arg1.parse::<u32>() {
Ok(n) if n > 0 => n,
_ => return "false".into(),
};
let target = NodeId::new(nid);
let (context, html) = arg2
.split_once('\0')
.unwrap_or(("body", arg2.as_str()));
for child in dom.children(target) {
dom.detach(child);
}
if !html.is_empty() {
let fragment =
obscura_dom::parse_fragment_with_context(html, context);
let import_root = fragment.find_body_or_root();
dom.import_children_from(target, &fragment, import_root);
}
"true".into()
}
@@ -547,9 +696,19 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
Ok(n) => n,
Err(_) => return "-1".into(),
};
dom.clone_node(NodeId::new(nid), arg2 == "true")
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
let source = NodeId::new(nid);
match dom.clone_node(source, arg2 == "true") {
Some(cloned) => {
propagate_script_start_state(
dom,
source,
cloned,
&gs.already_started_scripts,
);
cloned.index().to_string()
}
None => "-1".into(),
}
}
"create_element" => {
dom.new_node(NodeData::Element {
@@ -2265,6 +2424,8 @@ fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool
pub fn build_extension() -> Extension {
let mut ops = vec![
op_dom(),
op_script_mark_started(),
op_script_try_start(),
op_console_msg(),
op_fetch_url(),
op_get_cookies(),
+206 -2
View File
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use std::rc::Rc;
use deno_core::{JsRuntime, RuntimeOptions};
use obscura_dom::DomTree;
use obscura_dom::{DomTree, NodeId};
/// Re-exported so other crates (obscura-browser, obscura-cdp) can name the V8
/// isolate handle without taking a direct dependency on deno_core.
@@ -13,7 +13,10 @@ use crate::import_map::ImportMap;
use crate::module_loader::ObscuraModuleLoader;
#[cfg(feature = "render")]
use crate::ops::{clamp_scroll_offset, document_base_url, ensure_prepared_render};
use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
use crate::ops::{
build_extension, mark_script_subtree_started, node_is_script, ObscuraState,
StoredNetworkResponseBody,
};
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
@@ -217,6 +220,7 @@ impl ObscuraJsRuntime {
pub fn set_dom(&self, dom: DomTree) {
let mut gs = self.state.borrow_mut();
gs.dom = Some(dom);
gs.already_started_scripts.borrow_mut().clear();
// A new document owns a fresh retained scene and resource cache.
#[cfg(feature = "render")]
{
@@ -1204,6 +1208,39 @@ impl ObscuraJsRuntime {
state.dom.take()
}
/// Export document-owned script preparation state before the runtime realm
/// is temporarily destroyed. Page suspension keeps the DOM alive, so the
/// HTML "already started" flags must travel with it rather than resetting
/// like window-global JavaScript state.
pub fn started_script_ids(&self) -> Vec<u32> {
let state = self.state.borrow();
let mut ids = state
.already_started_scripts
.borrow()
.iter()
.map(|node_id| node_id.raw())
.collect::<Vec<_>>();
ids.sort_unstable();
ids
}
/// Restore script preparation state only onto script nodes in the current
/// DOM. Callers use this exclusively for the same DomTree surviving a
/// suspend/resume cycle; normal set_dom navigation starts from an empty set.
pub fn restore_started_script_ids(&self, ids: &[u32]) {
let state = self.state.borrow();
let Some(dom) = state.dom.as_ref() else {
return;
};
let valid = ids
.iter()
.copied()
.map(NodeId::new)
.filter(|node_id| node_is_script(dom, *node_id))
.collect::<Vec<_>>();
state.already_started_scripts.borrow_mut().extend(valid);
}
pub fn with_dom<R>(&self, f: impl FnOnce(&DomTree) -> R) -> Option<R> {
let state = self.state.borrow();
state.dom.as_ref().map(f)
@@ -1235,6 +1272,9 @@ impl ObscuraJsRuntime {
let fragment = obscura_dom::parse_fragment(html);
let root = fragment.fragment_root();
dom.import_children_from(body, &fragment, root);
for child in dom.children(body) {
mark_script_subtree_started(&state, child);
}
}
#[cfg(feature = "render")]
{
@@ -8112,4 +8152,168 @@ mod tests {
])
);
}
#[test]
fn html_string_scripts_remain_inert_when_connected() {
let mut rt = setup_runtime("<html><head></head><body><div id=target></div></body></html>");
let result = rt
.evaluate(
r#"
var scriptTestSetup = true;
globalThis.__fragmentScriptRuns = 0;
const direct = document.createElement("div");
direct.innerHTML = "<script>globalThis.__fragmentScriptRuns++<\/script>";
document.body.appendChild(direct.firstChild);
const nested = document.createElement("div");
nested.innerHTML = "<section><script>globalThis.__fragmentScriptRuns++<\/script></section>";
document.body.appendChild(nested.firstChild);
const template = document.createElement("template");
template.innerHTML = "<script>globalThis.__fragmentScriptRuns++<\/script>";
document.body.appendChild(template.content.firstChild);
const nestedTemplateHolder = document.createElement("div");
nestedTemplateHolder.innerHTML =
"<template><script>globalThis.__fragmentScriptRuns++<\/script></template>";
document.body.appendChild(
nestedTemplateHolder.firstChild.content.firstChild
);
document.getElementById("target").insertAdjacentHTML(
"beforeend",
"<script>globalThis.__fragmentScriptRuns++<\/script>"
);
const parsed = new DOMParser().parseFromString(
"<body><script>globalThis.__fragmentScriptRuns++<\/script></body>",
"text/html"
);
document.body.appendChild(parsed.querySelector("script"));
let externalFetches = 0;
const originalFetchOp = Deno.core.ops.op_fetch_url;
try {
Deno.core.ops.op_fetch_url = () => {
externalFetches++;
return JSON.stringify({
status: 200,
headers: {"content-type": "text/javascript"},
body: "globalThis.__fragmentScriptRuns++",
url: "http://example.com/inert.js"
});
};
const external = document.createElement("div");
external.innerHTML = "<script src=/inert.js><\/script>";
document.head.appendChild(external.firstChild);
} finally {
Deno.core.ops.op_fetch_url = originalFetchOp;
}
return [globalThis.__fragmentScriptRuns, externalFetches];
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!([0, 0]));
}
#[test]
fn connected_insertion_prepares_dynamic_script_subtrees_once() {
let mut rt = setup_runtime("<html><head></head><body><i id=anchor></i></body></html>");
let result = rt
.evaluate(
r#"
var scriptTestSetup = true;
globalThis.__dynamicScriptRuns = [];
const detached = document.createElement("div");
const delayed = document.createElement("script");
delayed.textContent = "globalThis.__dynamicScriptRuns.push('delayed')";
detached.appendChild(delayed);
const beforeConnection = globalThis.__dynamicScriptRuns.length;
document.body.appendChild(detached);
document.head.appendChild(delayed);
const before = document.createElement("script");
before.textContent = "globalThis.__dynamicScriptRuns.push('before')";
document.body.insertBefore(before, document.getElementById("anchor"));
const replacement = document.createElement("script");
replacement.textContent = "globalThis.__dynamicScriptRuns.push('replace')";
document.body.replaceChild(replacement, document.getElementById("anchor"));
const subtree = document.createElement("section");
const nested = document.createElement("script");
nested.textContent = "globalThis.__dynamicScriptRuns.push('nested')";
subtree.appendChild(nested);
document.body.appendChild(subtree);
return [beforeConnection, globalThis.__dynamicScriptRuns];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([0, ["delayed", "before", "replace", "nested"]])
);
}
#[test]
fn script_clone_preserves_started_state() {
let mut rt = setup_runtime(
"<html><head></head><body><script id=parser>globalThis.__cloneScriptRuns++</script></body></html>",
);
let result = rt
.evaluate(
r#"
var scriptTestSetup = true;
globalThis.__cloneScriptRuns = 0;
const parser = document.getElementById("parser");
globalThis.__markParserScripts([parser._nid]);
document.head.appendChild(parser);
document.body.appendChild(parser.cloneNode(true));
const dynamic = document.createElement("script");
dynamic.textContent = "globalThis.__cloneScriptRuns++";
document.body.appendChild(dynamic);
document.body.appendChild(dynamic.cloneNode(true));
const holder = document.createElement("div");
holder.innerHTML = "<script>globalThis.__cloneScriptRuns++<\/script>";
document.body.appendChild(holder.firstChild.cloneNode(true));
const fragment = document.createDocumentFragment();
fragment.appendChild(dynamic.cloneNode(true));
document.body.appendChild(fragment.cloneNode(true));
return globalThis.__cloneScriptRuns;
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(1.0));
}
#[test]
fn contextual_fragment_and_document_write_keep_executable_script_policy() {
let mut rt = setup_runtime("<html><head></head><body><div id=context></div></body></html>");
let result = rt
.evaluate(
r#"
var scriptTestSetup = true;
globalThis.__executableFragmentRuns = [];
const range = document.createRange();
range.selectNode(document.getElementById("context"));
const fragment = range.createContextualFragment(
"<template><script>globalThis.__executableFragmentRuns.push('template')<\/script></template>" +
"<script>globalThis.__executableFragmentRuns.push('range')<\/script>"
);
document.body.appendChild(fragment);
document.write(
"<script>globalThis.__executableFragmentRuns.push('write')<\/script>"
);
return globalThis.__executableFragmentRuns;
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(["range", "write"]));
}
}