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,