Preserve script execution state across DOM moves
This commit is contained in:
1 parent
6dec05f171
commit
41a8e1c1a6
4 files changed
+626
-116
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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"]));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user