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

+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"]));
}
}