Preserve script execution state across DOM moves
This commit is contained in:
1 parent
6dec05f171
commit
41a8e1c1a6
4 files changed
+625
-115
No files matched your search
@@ -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() {
|
||||
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,
|
||||
|
||||
Vendored
+124
-106
@@ -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.
|
||||
|
||||
@@ -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