Align load readiness and paginated fixed content
This commit is contained in:
1 parent
03f506b01a
commit
1f8a4f0baa
7 files changed
+611
-91
No files matched your search
@@ -1116,6 +1116,44 @@ impl Page {
|
|||||||
self.execute_scripts_with_module_budget(None).await;
|
self.execute_scripts_with_module_budget(None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive only dynamic script elements which participate in the current
|
||||||
|
/// document's load-event delay set. Browser script runners keep this set
|
||||||
|
/// separate from arbitrary post-load imports, timers, and enhancement
|
||||||
|
/// scripts; navigation readiness must not turn those into an implicit
|
||||||
|
/// multi-second settle.
|
||||||
|
async fn drive_load_delaying_scripts(
|
||||||
|
js: &mut ObscuraJsRuntime,
|
||||||
|
deadline: tokio::time::Instant,
|
||||||
|
) -> bool {
|
||||||
|
while js.has_pending_load_delaying_scripts() {
|
||||||
|
let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if remaining.is_zero() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let poll_budget = remaining.min(tokio::time::Duration::from_millis(25));
|
||||||
|
match tokio::time::timeout(poll_budget, js.run_event_loop()).await {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
if js.has_pending_load_delaying_scripts() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
tracing::warn!("load-delaying dynamic script event loop failed: {error}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// The poll budget is a scheduling quantum, not a script
|
||||||
|
// timeout. Keep polling until the shared navigation/script
|
||||||
|
// deadline; the phase watchdog handles a native V8 pin.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute_scripts_with_module_budget(&mut self, module_budget_override: Option<u64>) {
|
async fn execute_scripts_with_module_budget(&mut self, module_budget_override: Option<u64>) {
|
||||||
let scripts_started = std::time::Instant::now();
|
let scripts_started = std::time::Instant::now();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -1751,6 +1789,16 @@ impl Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parsing has finished before defer scripts and non-async modules run.
|
||||||
|
// They still gate DOMContentLoaded, but observe the browser's
|
||||||
|
// `interactive` readyState while they execute.
|
||||||
|
if let Some(js) = &mut self.js {
|
||||||
|
let _ = js.execute_script(
|
||||||
|
"<ready-state-interactive>",
|
||||||
|
"globalThis.__documentReadyState__ = 'interactive';",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for scheduled in post_parse {
|
for scheduled in post_parse {
|
||||||
if tokio::time::Instant::now() >= script_deadline {
|
if tokio::time::Instant::now() >= script_deadline {
|
||||||
tracing::warn!("execute_scripts: deadline reached during post-parse scripts");
|
tracing::warn!("execute_scripts: deadline reached during post-parse scripts");
|
||||||
@@ -1817,81 +1865,33 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(js) = &mut self.js {
|
if let Some(js) = &mut self.js {
|
||||||
// Spec order: readyState -> interactive, fire DOMContentLoaded on both
|
// DOMContentLoaded follows parser/defer/module work, but async
|
||||||
// document and window, then readyState -> complete, fire load.
|
// dynamic script elements do not gate it. They do remain in the
|
||||||
let _ = js.execute_script("<load-events>",
|
// document's load-event delay set, including scripts inserted by
|
||||||
"globalThis.__documentReadyState__ = 'interactive';\n\
|
// a DOMContentLoaded listener.
|
||||||
try { document.dispatchEvent(new Event('DOMContentLoaded', {bubbles:false,cancelable:false})); } catch(e) {}\n\
|
let _ = js.execute_script(
|
||||||
try { window.dispatchEvent(new Event('DOMContentLoaded', {bubbles:false,cancelable:false})); } catch(e) {}\n\
|
"<dom-content-loaded>",
|
||||||
|
"try { document.dispatchEvent(new Event('DOMContentLoaded', {bubbles:false,cancelable:false})); } catch(e) {}\n\
|
||||||
|
try { window.dispatchEvent(new Event('DOMContentLoaded', {bubbles:false,cancelable:false})); } catch(e) {}",
|
||||||
|
);
|
||||||
|
|
||||||
|
let load_blockers_finished =
|
||||||
|
Self::drive_load_delaying_scripts(js, script_deadline).await;
|
||||||
|
if !load_blockers_finished {
|
||||||
|
tracing::warn!(
|
||||||
|
"script deadline reached with load-delaying dynamic scripts still pending"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// readyState becomes complete before the load event. A script
|
||||||
|
// inserted by an onload handler is therefore post-load work and
|
||||||
|
// remains pending until an explicit caller settle/wait.
|
||||||
|
let _ = js.execute_script(
|
||||||
|
"<load-event>",
|
||||||
|
"globalThis.__documentReadyState__ = 'complete';\n\
|
||||||
if (typeof window.onload === 'function') { try { window.onload(); } catch(e) {} }\n\
|
if (typeof window.onload === 'function') { try { window.onload(); } catch(e) {} }\n\
|
||||||
globalThis.__documentReadyState__ = 'complete';\n\
|
try { window.dispatchEvent(new Event('load', {bubbles:false,cancelable:false})); } catch(e) {}",
|
||||||
try { window.dispatchEvent(new Event('load', {bubbles:false,cancelable:false})); } catch(e) {}");
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(js) = &mut self.js {
|
|
||||||
let dynamic_settle_ms = std::env::var("OBSCURA_DYNAMIC_SCRIPT_SETTLE_MS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
|
||||||
.unwrap_or(3_000)
|
|
||||||
.max(500);
|
|
||||||
// Bound the post-script settle loop by wall clock, not just by the
|
|
||||||
// 10ms-tick branch. The old code only consulted `deadline` inside
|
|
||||||
// the `Err(_)` arm (when the inner tick timed out), so a steady
|
|
||||||
// stream of inflight XHR/fetch (active_requests() > 0) kept the
|
|
||||||
// loop running indefinitely because it took the `Ok(Ok(()))` arm
|
|
||||||
// and slept 1ms each iteration without ever checking the clock.
|
|
||||||
// On busy sites this could keep the V8 lock held for tens of
|
|
||||||
// seconds, wedging the entire CDP dispatcher (see triage for
|
|
||||||
// issue series around the 40-site compat sweep).
|
|
||||||
// A dynamic external script may still be in flight at 500ms. Keep
|
|
||||||
// pumping only while that queue is pending, up to a separate bounded
|
|
||||||
// budget, so normal pages and unrelated fetches retain the fast path.
|
|
||||||
// A single run_event_loop poll that pins the thread inside V8 makes
|
|
||||||
// the per-poll tokio timeouts below useless, so guard the whole loop
|
|
||||||
// with a watchdog that fires 250ms past the longest deadline.
|
|
||||||
let settle_wd =
|
|
||||||
js.arm_watchdog(std::time::Duration::from_millis(dynamic_settle_ms + 250));
|
|
||||||
let started = tokio::time::Instant::now();
|
|
||||||
let deadline = started + tokio::time::Duration::from_millis(500);
|
|
||||||
let dynamic_deadline = started + tokio::time::Duration::from_millis(dynamic_settle_ms);
|
|
||||||
let mut idle_count = 0u32;
|
|
||||||
loop {
|
|
||||||
let now = tokio::time::Instant::now();
|
|
||||||
// `has_pending_dynamic_scripts` includes both inserted script
|
|
||||||
// elements and loader-owned ES-module graph activity. A lazy
|
|
||||||
// import may be the work which removes an SSR loading shell;
|
|
||||||
// do not take the 500ms fast exit while that graph is fetching.
|
|
||||||
// Unrelated fetch/XHR activity remains on the fast path.
|
|
||||||
if now >= deadline && (now >= dynamic_deadline || !js.has_pending_dynamic_scripts())
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let result = tokio::time::timeout(
|
|
||||||
tokio::time::Duration::from_millis(10),
|
|
||||||
js.run_event_loop(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => {
|
|
||||||
if self.http_client.active_requests() == 0 {
|
|
||||||
idle_count += 1;
|
|
||||||
if idle_count >= 2 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
} else {
|
|
||||||
idle_count = 0;
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Err(_)) => break,
|
|
||||||
Err(_) => {
|
|
||||||
idle_count = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
js.disarm_watchdog(settle_wd);
|
|
||||||
}
|
}
|
||||||
if let Some(token) = exec_wd {
|
if let Some(token) = exec_wd {
|
||||||
if let Some(js) = self.js.as_mut() {
|
if let Some(js) = self.js.as_mut() {
|
||||||
@@ -3734,6 +3734,36 @@ mod tests {
|
|||||||
(format!("http://{}", address), request_rx)
|
(format!("http://{}", address), request_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_delayed_classic_script_server(
|
||||||
|
delay: std::time::Duration,
|
||||||
|
body: &'static str,
|
||||||
|
) -> (String, std::sync::mpsc::Receiver<String>) {
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let (request_tx, request_rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = [0u8; 2048];
|
||||||
|
let length = stream.read(&mut request).unwrap();
|
||||||
|
let path = String::from_utf8_lossy(&request[..length])
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.and_then(|line| line.split_ascii_whitespace().nth(1))
|
||||||
|
.unwrap_or("/")
|
||||||
|
.to_string();
|
||||||
|
request_tx.send(path).unwrap();
|
||||||
|
std::thread::sleep(delay);
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len(),
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).unwrap();
|
||||||
|
});
|
||||||
|
(format!("http://{address}"), request_rx)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn external_scripts_require_a_successful_http_status() {
|
fn external_scripts_require_a_successful_http_status() {
|
||||||
assert!(script_response_is_executable(200));
|
assert!(script_response_is_executable(200));
|
||||||
@@ -4093,6 +4123,7 @@ mod tests {
|
|||||||
</head><body></body></html>"#,
|
</head><body></body></html>"#,
|
||||||
);
|
);
|
||||||
page.execute_scripts().await;
|
page.execute_scripts().await;
|
||||||
|
page.settle_for_duration(500).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.js
|
page.js
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -4115,6 +4146,7 @@ mod tests {
|
|||||||
</head><body></body></html>"#,
|
</head><body></body></html>"#,
|
||||||
);
|
);
|
||||||
page.execute_scripts().await;
|
page.execute_scripts().await;
|
||||||
|
page.settle_for_duration(500).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.js
|
page.js
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -4151,6 +4183,7 @@ mod tests {
|
|||||||
</body></html>"#,
|
</body></html>"#,
|
||||||
);
|
);
|
||||||
page.execute_scripts().await;
|
page.execute_scripts().await;
|
||||||
|
page.settle_for_duration(500).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.js
|
page.js
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -4168,7 +4201,168 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn lazy_module_graph_keeps_post_script_settle_alive() {
|
async fn preload_dynamic_script_delays_load_but_not_dom_content_loaded() {
|
||||||
|
let (base, requests) = spawn_delayed_classic_script_server(
|
||||||
|
std::time::Duration::from_millis(150),
|
||||||
|
"globalThis.__lifecycleOrder.push('dynamic-exec');",
|
||||||
|
);
|
||||||
|
let html = format!(
|
||||||
|
r#"<html><head></head><body><script>
|
||||||
|
globalThis.__lifecycleOrder = [];
|
||||||
|
document.addEventListener('DOMContentLoaded', () =>
|
||||||
|
globalThis.__lifecycleOrder.push('dom-content-loaded'));
|
||||||
|
window.onload = () =>
|
||||||
|
globalThis.__lifecycleOrder.push('window-onload');
|
||||||
|
window.addEventListener('load', () =>
|
||||||
|
globalThis.__lifecycleOrder.push('window-load'));
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = '{base}/preload-dynamic.js';
|
||||||
|
script.onload = () => globalThis.__lifecycleOrder.push('script-load');
|
||||||
|
document.head.appendChild(script);
|
||||||
|
</script></body></html>"#,
|
||||||
|
);
|
||||||
|
let mut page = import_map_test_page("preload-dynamic-lifecycle", &base, &html);
|
||||||
|
|
||||||
|
page.execute_scripts().await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
requests
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(1))
|
||||||
|
.unwrap(),
|
||||||
|
"/preload-dynamic.js",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
page.js
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.evaluate("globalThis.__lifecycleOrder")
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!([
|
||||||
|
"dom-content-loaded",
|
||||||
|
"dynamic-exec",
|
||||||
|
"script-load",
|
||||||
|
"window-onload",
|
||||||
|
"window-load"
|
||||||
|
]),
|
||||||
|
"dynamic async scripts gate load, not DOMContentLoaded",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
page.js
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.evaluate(
|
||||||
|
"globalThis.__lifecycleOrder.filter(value => value === 'window-onload').length",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!(1.0),
|
||||||
|
"window.onload must fire exactly once",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn post_load_dynamic_script_waits_only_when_caller_requests_settle() {
|
||||||
|
let (base, requests) = spawn_delayed_classic_script_server(
|
||||||
|
std::time::Duration::from_millis(400),
|
||||||
|
"globalThis.__postLoadDynamicRan = true;",
|
||||||
|
);
|
||||||
|
let html = format!(
|
||||||
|
r#"<html><body><script>
|
||||||
|
window.addEventListener('load', () => {{
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = '{base}/post-load.js';
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}});
|
||||||
|
</script></body></html>"#,
|
||||||
|
);
|
||||||
|
let mut page = import_map_test_page("post-load-dynamic-lifecycle", &base, &html);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
|
||||||
|
page.execute_scripts().await;
|
||||||
|
|
||||||
|
let navigation_elapsed = started.elapsed();
|
||||||
|
assert!(
|
||||||
|
navigation_elapsed < std::time::Duration::from_millis(300),
|
||||||
|
"post-load enhancement must not extend navigation; elapsed={navigation_elapsed:?}",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
page.js
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.evaluate(
|
||||||
|
"[document.readyState, globalThis.__postLoadDynamicRan === true, \
|
||||||
|
globalThis.__obscura_hasPendingDynamicScripts(), \
|
||||||
|
globalThis.__obscura_hasPendingLoadDelayingScripts()]",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!(["complete", false, true, false]),
|
||||||
|
"a script prepared by load is pending enhancement work, not a load blocker",
|
||||||
|
);
|
||||||
|
|
||||||
|
page.settle_for_duration(700).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
requests
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(1))
|
||||||
|
.unwrap(),
|
||||||
|
"/post-load.js",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
page.js
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.evaluate("globalThis.__postLoadDynamicRan === true")
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!(true),
|
||||||
|
"an explicit caller settle must drive post-load script completion",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn timer_hydration_runs_during_explicit_adaptive_settle_not_navigation_load() {
|
||||||
|
let mut page = import_map_test_page(
|
||||||
|
"timer-hydration-lifecycle",
|
||||||
|
"http://example.com",
|
||||||
|
r#"<html><body><main id="app">Server shell</main><script>
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('app').textContent = 'Hydrated app';
|
||||||
|
document.body.setAttribute('data-hydrated', 'true');
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
</script></body></html>"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
page.execute_scripts().await;
|
||||||
|
assert_eq!(
|
||||||
|
page.js
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.evaluate(
|
||||||
|
"[document.readyState, document.body.getAttribute('data-hydrated'), \
|
||||||
|
document.getElementById('app').textContent]",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!(["complete", null, "Server shell"]),
|
||||||
|
"navigation load observes load semantics without inventing a timer settle",
|
||||||
|
);
|
||||||
|
|
||||||
|
page.settle(500).await;
|
||||||
|
assert_eq!(
|
||||||
|
page.js
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.evaluate(
|
||||||
|
"[document.body.getAttribute('data-hydrated'), \
|
||||||
|
document.getElementById('app').textContent]",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!(["true", "Hydrated app"]),
|
||||||
|
"the automation caller's adaptive settle must retain timer hydration",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn lazy_module_graph_is_post_load_work_until_caller_settles() {
|
||||||
use std::io::{Read as _, Write as _};
|
use std::io::{Read as _, Write as _};
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
@@ -4215,8 +4409,24 @@ mod tests {
|
|||||||
});
|
});
|
||||||
</script></body></html>"#,
|
</script></body></html>"#,
|
||||||
);
|
);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
page.execute_scripts().await;
|
page.execute_scripts().await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < std::time::Duration::from_millis(500),
|
||||||
|
"dynamic import() must not become an implicit navigation settle",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
page.js
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.evaluate("document.body.getAttribute('data-lazy-state')")
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::Value::Null,
|
||||||
|
);
|
||||||
|
|
||||||
|
page.settle_for_duration(1_000).await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.js
|
page.js
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -4224,7 +4434,7 @@ mod tests {
|
|||||||
.evaluate("document.body.getAttribute('data-lazy-state')")
|
.evaluate("document.body.getAttribute('data-lazy-state')")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
serde_json::json!("lazy-ready"),
|
serde_json::json!("lazy-ready"),
|
||||||
"post-script readiness must include an in-flight lazy module graph",
|
"an explicit caller settle must drive the lazy module graph",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4309,6 +4519,7 @@ mod tests {
|
|||||||
</body></html>"#,
|
</body></html>"#,
|
||||||
);
|
);
|
||||||
page.execute_scripts().await;
|
page.execute_scripts().await;
|
||||||
|
page.settle_for_duration(500).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.js
|
page.js
|
||||||
.as_mut()
|
.as_mut()
|
||||||
|
|||||||
@@ -342,8 +342,9 @@ impl Page {
|
|||||||
let y = page_index as f32 * plan.css_page_height;
|
let y = page_index as f32 * plan.css_page_height;
|
||||||
let slice_height = (content_height - y).min(plan.css_page_height);
|
let slice_height = (content_height - y).min(plan.css_page_height);
|
||||||
let png = js
|
let png = js
|
||||||
.screenshot_prepared_region_with_backgrounds(
|
.screenshot_prepared_region_at_scroll_with_backgrounds(
|
||||||
CaptureRegion::new(0.0, y, content_width, slice_height, 1.0),
|
CaptureRegion::new(0.0, y, content_width, slice_height, 1.0),
|
||||||
|
(0.0, y),
|
||||||
options.print_background,
|
options.print_background,
|
||||||
)
|
)
|
||||||
.map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?;
|
.map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?;
|
||||||
@@ -533,6 +534,57 @@ impl io::Write for PdfWriter {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
haystack
|
||||||
|
.windows(needle.len())
|
||||||
|
.position(|window| window == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the actual JPEG XObjects emitted into the PDF, rather than
|
||||||
|
/// trusting pagination options or writer-internal page counters.
|
||||||
|
fn pdf_page_rasters(pdf: &[u8]) -> Vec<image::RgbImage> {
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(relative) = find_bytes(&pdf[cursor..], b"/Subtype /Image") {
|
||||||
|
let image_object = cursor + relative;
|
||||||
|
let length_key = image_object
|
||||||
|
+ find_bytes(&pdf[image_object..], b"/Length ").expect("image /Length");
|
||||||
|
let mut digits_start = length_key + b"/Length ".len();
|
||||||
|
while pdf[digits_start].is_ascii_whitespace() {
|
||||||
|
digits_start += 1;
|
||||||
|
}
|
||||||
|
let mut digits_end = digits_start;
|
||||||
|
while pdf[digits_end].is_ascii_digit() {
|
||||||
|
digits_end += 1;
|
||||||
|
}
|
||||||
|
let length = std::str::from_utf8(&pdf[digits_start..digits_end])
|
||||||
|
.expect("ASCII image length")
|
||||||
|
.parse::<usize>()
|
||||||
|
.expect("numeric image length");
|
||||||
|
let stream_start = digits_end
|
||||||
|
+ find_bytes(&pdf[digits_end..], b"stream\n").expect("image stream")
|
||||||
|
+ b"stream\n".len();
|
||||||
|
let stream_end = stream_start.checked_add(length).expect("bounded stream end");
|
||||||
|
let raster = image::load_from_memory_with_format(
|
||||||
|
&pdf[stream_start..stream_end],
|
||||||
|
image::ImageFormat::Jpeg,
|
||||||
|
)
|
||||||
|
.expect("decodable page JPEG")
|
||||||
|
.into_rgb8();
|
||||||
|
pages.push(raster);
|
||||||
|
cursor = stream_end;
|
||||||
|
}
|
||||||
|
pages
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_near(actual: image::Rgb<u8>, expected: [u8; 3]) -> bool {
|
||||||
|
actual
|
||||||
|
.0
|
||||||
|
.into_iter()
|
||||||
|
.zip(expected)
|
||||||
|
.all(|(actual, expected)| (i16::from(actual) - i16::from(expected)).abs() <= 20)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn options_reject_impossible_media_boxes() {
|
fn options_reject_impossible_media_boxes() {
|
||||||
let mut options = RasterPdfOptions::default();
|
let mut options = RasterPdfOptions::default();
|
||||||
@@ -711,6 +763,88 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raster_pdf_repeats_fixed_content_and_advances_flow_on_every_selected_page() {
|
||||||
|
let context = std::sync::Arc::new(crate::BrowserContext::new("pdf-fixed".to_string()));
|
||||||
|
let mut page = crate::Page::new("pdf-fixed-page".to_string(), context);
|
||||||
|
page.set_viewport((100.0, 80.0));
|
||||||
|
let dom = obscura_dom::parse_html(
|
||||||
|
r#"<html style="margin:0"><body style="margin:0;width:100px;height:200px">
|
||||||
|
<div style="position:fixed;z-index:5;left:0;top:0;width:20px;height:10px;background:#111"></div>
|
||||||
|
<div style="height:80px;background:#e02020"></div>
|
||||||
|
<div style="height:80px;background:#20c040"></div>
|
||||||
|
<div style="height:40px;background:#2050e0"></div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new();
|
||||||
|
runtime.set_dom(dom);
|
||||||
|
runtime.set_url("https://example.test/pdf-fixed");
|
||||||
|
runtime.set_viewport(100.0, 80.0);
|
||||||
|
runtime.run_page_init();
|
||||||
|
page.js = Some(runtime);
|
||||||
|
|
||||||
|
let options = RasterPdfOptions {
|
||||||
|
print_background: true,
|
||||||
|
paper_width_in: 100.0 / POINTS_PER_INCH,
|
||||||
|
paper_height_in: 80.0 / POINTS_PER_INCH,
|
||||||
|
margin_top_in: 0.0,
|
||||||
|
margin_bottom_in: 0.0,
|
||||||
|
margin_left_in: 0.0,
|
||||||
|
margin_right_in: 0.0,
|
||||||
|
..RasterPdfOptions::default()
|
||||||
|
};
|
||||||
|
let pdf = page.raster_pdf(options.clone()).expect("three-page PDF");
|
||||||
|
assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 100.000 80.000]"));
|
||||||
|
let rasters = pdf_page_rasters(&pdf);
|
||||||
|
assert_eq!(rasters.len(), 3);
|
||||||
|
assert_eq!(rasters[0].dimensions(), (100, 80));
|
||||||
|
assert_eq!(rasters[1].dimensions(), (100, 80));
|
||||||
|
assert_eq!(
|
||||||
|
rasters[2].dimensions(),
|
||||||
|
(100, 40),
|
||||||
|
"the final partial page must use its own virtual viewport height"
|
||||||
|
);
|
||||||
|
for (index, raster) in rasters.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
channel_near(*raster.get_pixel(5, 5), [17, 17, 17]),
|
||||||
|
"fixed header missing from decoded page {}: {:?}",
|
||||||
|
index + 1,
|
||||||
|
raster.get_pixel(5, 5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (index, expected) in [[224, 32, 32], [32, 192, 64], [32, 80, 224]]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let raster = &rasters[index];
|
||||||
|
assert!(
|
||||||
|
channel_near(
|
||||||
|
*raster.get_pixel(raster.width() / 2, raster.height() / 2),
|
||||||
|
expected,
|
||||||
|
),
|
||||||
|
"ordinary flow did not advance on page {}",
|
||||||
|
index + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
page.js.as_ref().expect("runtime").scroll_offset(),
|
||||||
|
(0.0, 0.0),
|
||||||
|
"virtual PDF page scrolling must not mutate the live page"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut ranged = options;
|
||||||
|
ranged.page_ranges = vec![RasterPdfPageRange {
|
||||||
|
start: Some(2),
|
||||||
|
end: Some(3),
|
||||||
|
}];
|
||||||
|
let selected = pdf_page_rasters(&page.raster_pdf(ranged).expect("selected pages"));
|
||||||
|
assert_eq!(selected.len(), 2);
|
||||||
|
assert!(channel_near(*selected[0].get_pixel(5, 5), [17, 17, 17]));
|
||||||
|
assert!(channel_near(*selected[1].get_pixel(5, 5), [17, 17, 17]));
|
||||||
|
assert!(channel_near(*selected[0].get_pixel(50, 40), [32, 192, 64]));
|
||||||
|
assert!(channel_near(*selected[1].get_pixel(50, 20), [32, 80, 224]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn writer_emits_xref_and_one_image_per_page() {
|
fn writer_emits_xref_and_one_image_per_page() {
|
||||||
let pdf = encode_pdf_pages(1, 612.0, 792.0, 36.0, 36.0, 720.0, |_| {
|
let pdf = encode_pdf_pages(1, 612.0, 792.0, 36.0, 36.0, 720.0, |_| {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Regression for issue #474: external scripts inserted after a timer must be
|
// Regression for issue #474: external scripts inserted after a timer must be
|
||||||
// fetched and execute before the post-navigation event-loop settle completes.
|
// fetched and execute before an explicit post-navigation settle completes.
|
||||||
|
|
||||||
use obscura_cdp::dispatch::{dispatch, CdpContext};
|
use obscura_cdp::dispatch::{dispatch, CdpContext};
|
||||||
use obscura_cdp::types::CdpRequest;
|
use obscura_cdp::types::CdpRequest;
|
||||||
@@ -100,7 +100,7 @@ async fn dynamic_external_scripts_execute_and_fire_load() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let result = cdp(
|
let at_load = cdp(
|
||||||
&mut ctx,
|
&mut ctx,
|
||||||
2,
|
2,
|
||||||
"Runtime.evaluate",
|
"Runtime.evaluate",
|
||||||
@@ -112,9 +112,31 @@ async fn dynamic_external_scripts_execute_and_fire_load() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result["result"]["value"],
|
at_load["result"]["value"],
|
||||||
|
r#"{"directExecuted":false,"directLoaded":false,"nestedExecuted":false,"nestedLoaded":false}"#,
|
||||||
|
"waitUntil=load must not invent a post-load timer settle"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The automation caller opts into post-load work. This must drive the
|
||||||
|
// timer, both concurrently fetched scripts, their bodies, and their load
|
||||||
|
// handlers without relying on navigation to exceed browser load semantics.
|
||||||
|
ctx.pages[0].settle(1_500).await;
|
||||||
|
|
||||||
|
let settled = cdp(
|
||||||
|
&mut ctx,
|
||||||
|
3,
|
||||||
|
"Runtime.evaluate",
|
||||||
|
json!({
|
||||||
|
"expression": "JSON.stringify({directExecuted: !!window.__directExecuted, directLoaded: !!window.__directLoaded, nestedExecuted: !!window.__nestedExecuted, nestedLoaded: !!window.__nestedLoaded})",
|
||||||
|
"returnByValue": true,
|
||||||
|
}),
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
settled["result"]["value"],
|
||||||
r#"{"directExecuted":true,"directLoaded":true,"nestedExecuted":true,"nestedLoaded":true}"#,
|
r#"{"directExecuted":true,"directLoaded":true,"nestedExecuted":true,"nestedLoaded":true}"#,
|
||||||
"dynamic scripts must execute and fire load before navigation settles"
|
"dynamic scripts must execute and fire load before explicit settle completes"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,13 +104,15 @@ async fn js_fetch_emits_network_request_and_response() {
|
|||||||
let session_id = "session-1";
|
let session_id = "session-1";
|
||||||
ctx.sessions.insert(session_id.to_string(), page_id.clone());
|
ctx.sessions.insert(session_id.to_string(), page_id.clone());
|
||||||
|
|
||||||
// Navigate with waitUntil:load so the after-load fetch() runs and settles
|
// An ordinary fetch() is not load-delaying in Chromium: `load` may fire
|
||||||
// before the navigation emits its Network events.
|
// while its response is still pending. Ask for networkidle0 explicitly so
|
||||||
|
// this output-level assertion observes the completed request without
|
||||||
|
// turning every load navigation into an implicit global settle.
|
||||||
cdp(
|
cdp(
|
||||||
&mut ctx,
|
&mut ctx,
|
||||||
1,
|
1,
|
||||||
"Page.navigate",
|
"Page.navigate",
|
||||||
json!({"url": base, "waitUntil": "load"}),
|
json!({"url": base, "waitUntil": "networkidle0"}),
|
||||||
session_id,
|
session_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
Vendored
+45
-3
@@ -16,7 +16,10 @@
|
|||||||
'__obscura_objects', '__obscura_oid', '__obscura_ua',
|
'__obscura_objects', '__obscura_oid', '__obscura_ua',
|
||||||
'__obscura_platform', '__obscura_ua_platform', '__obscura_ua_platform_version',
|
'__obscura_platform', '__obscura_ua_platform', '__obscura_ua_platform_version',
|
||||||
'__obscura_stealth', '__obscura_markTrusted',
|
'__obscura_stealth', '__obscura_markTrusted',
|
||||||
'__obscura_hw', '__obscura_mem', '__markParserScripts',
|
'__markParserScripts', '__obscura_hasPendingDynamicScripts',
|
||||||
|
'__obscura_hasPendingLoadDelayingScripts',
|
||||||
|
'__obscura_nextPendingTimeoutDelay',
|
||||||
|
'__obscura_hw', '__obscura_mem',
|
||||||
'__documentReadyState__', '__currentUrl',
|
'__documentReadyState__', '__currentUrl',
|
||||||
// internal helpers (var-declared throughout the file)
|
// internal helpers (var-declared throughout the file)
|
||||||
'__processDynScriptQueue', '_decodeDataScriptUrl', '_markNative', '_fpRand', '_fpNoise',
|
'__processDynScriptQueue', '_decodeDataScriptUrl', '_markNative', '_fpRand', '_fpNoise',
|
||||||
@@ -199,6 +202,7 @@ let _fpSeed = 0;
|
|||||||
let __dynScriptQueue = [];
|
let __dynScriptQueue = [];
|
||||||
let __dynScriptBusy = false;
|
let __dynScriptBusy = false;
|
||||||
let __dynClassicPending = 0;
|
let __dynClassicPending = 0;
|
||||||
|
let __dynLoadDelayingPending = 0;
|
||||||
Object.defineProperty(globalThis, '__obscura_hasPendingDynamicScripts', {
|
Object.defineProperty(globalThis, '__obscura_hasPendingDynamicScripts', {
|
||||||
value: function() {
|
value: function() {
|
||||||
return __dynClassicPending > 0 || __dynScriptBusy || __dynScriptQueue.length > 0;
|
return __dynClassicPending > 0 || __dynScriptBusy || __dynScriptQueue.length > 0;
|
||||||
@@ -207,6 +211,17 @@ Object.defineProperty(globalThis, '__obscura_hasPendingDynamicScripts', {
|
|||||||
enumerable: false,
|
enumerable: false,
|
||||||
configurable: false,
|
configurable: false,
|
||||||
});
|
});
|
||||||
|
// HTML tracks scripts which delay the document load event separately from
|
||||||
|
// arbitrary asynchronous script work. A connected external script prepared
|
||||||
|
// before `load` joins that set until its load/error processing finishes;
|
||||||
|
// dynamic import() and scripts created by a load handler are post-load work.
|
||||||
|
// Keep this bridge hidden for the same reason as the general queue status.
|
||||||
|
Object.defineProperty(globalThis, '__obscura_hasPendingLoadDelayingScripts', {
|
||||||
|
value: function() { return __dynLoadDelayingPending > 0; },
|
||||||
|
writable: false,
|
||||||
|
enumerable: false,
|
||||||
|
configurable: false,
|
||||||
|
});
|
||||||
function _decodeDataScriptUrl(url) {
|
function _decodeDataScriptUrl(url) {
|
||||||
const comma = url.indexOf(',');
|
const comma = url.indexOf(',');
|
||||||
if (!url.startsWith('data:') || comma < 5) {
|
if (!url.startsWith('data:') || comma < 5) {
|
||||||
@@ -305,6 +320,11 @@ async function __runDynScriptTask(task) {
|
|||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Dynamic script fetch error:', e.message);
|
console.error('Dynamic script fetch error:', e.message);
|
||||||
try { task.dispatchEvent(new Event('error')); } catch(ex) {}
|
try { task.dispatchEvent(new Event('error')); } catch(ex) {}
|
||||||
|
} finally {
|
||||||
|
if (task.delaysLoad) {
|
||||||
|
task.delaysLoad = false;
|
||||||
|
__dynLoadDelayingPending = Math.max(0, __dynLoadDelayingPending - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function __runAsyncClassicScript(task) {
|
async function __runAsyncClassicScript(task) {
|
||||||
@@ -658,6 +678,19 @@ const _clearedTimers = new Set();
|
|||||||
const _intervals = new Set();
|
const _intervals = new Set();
|
||||||
const _nativeTimerIds = new Map();
|
const _nativeTimerIds = new Map();
|
||||||
const __obscuraPendingTimeoutDeadlines = new Map();
|
const __obscuraPendingTimeoutDeadlines = new Map();
|
||||||
|
Object.defineProperty(globalThis, '__obscura_nextPendingTimeoutDelay', {
|
||||||
|
value: function() {
|
||||||
|
const now = performance.now();
|
||||||
|
let nearest = Infinity;
|
||||||
|
for (const deadline of __obscuraPendingTimeoutDeadlines.values()) {
|
||||||
|
nearest = Math.min(nearest, Math.max(0, deadline - now));
|
||||||
|
}
|
||||||
|
return Number.isFinite(nearest) ? nearest : -1;
|
||||||
|
},
|
||||||
|
writable: false,
|
||||||
|
enumerable: false,
|
||||||
|
configurable: false,
|
||||||
|
});
|
||||||
|
|
||||||
const _scheduleAfter = (delay, fn) => {
|
const _scheduleAfter = (delay, fn) => {
|
||||||
const d = Math.max(0, Number(delay) || 0);
|
const d = Math.max(0, Number(delay) || 0);
|
||||||
@@ -1327,6 +1360,12 @@ function __prepareInsertedScript(script) {
|
|||||||
pageOrigin,
|
pageOrigin,
|
||||||
dispatchEvent: (ev) => { try { script.dispatchEvent(ev); } catch(e) {} },
|
dispatchEvent: (ev) => { try { script.dispatchEvent(ev); } catch(e) {} },
|
||||||
};
|
};
|
||||||
|
// Non-parser-inserted external scripts are async by default, but scripts
|
||||||
|
// prepared while the document is still loading still delay window.load.
|
||||||
|
// Snapshot the flag at preparation time: changing readyState later must
|
||||||
|
// not turn already-prepared work into a post-load enhancement.
|
||||||
|
task.delaysLoad = globalThis.document?.readyState !== 'complete';
|
||||||
|
if (task.delaysLoad) __dynLoadDelayingPending++;
|
||||||
// A non-parser-inserted classic script is force-async unless script code
|
// A non-parser-inserted classic script is force-async unless script code
|
||||||
// explicitly assigned `.async = false`. Keep that opt-out in insertion
|
// explicitly assigned `.async = false`. Keep that opt-out in insertion
|
||||||
// order; default/async=true scripts fetch concurrently and execute as soon
|
// order; default/async=true scripts fetch concurrently and execute as soon
|
||||||
@@ -1342,14 +1381,17 @@ function __prepareInsertedScript(script) {
|
|||||||
}
|
}
|
||||||
} else if (isModule) {
|
} else if (isModule) {
|
||||||
const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)));
|
const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)));
|
||||||
__dynScriptQueue.push({
|
const task = {
|
||||||
url: dataUrl,
|
url: dataUrl,
|
||||||
isModule: true,
|
isModule: true,
|
||||||
nid: script._nid,
|
nid: script._nid,
|
||||||
prevNid,
|
prevNid,
|
||||||
pageOrigin: "",
|
pageOrigin: "",
|
||||||
dispatchEvent: (ev) => { try { script.dispatchEvent(ev); } catch(e) {} },
|
dispatchEvent: (ev) => { try { script.dispatchEvent(ev); } catch(e) {} },
|
||||||
});
|
delaysLoad: globalThis.document?.readyState !== 'complete',
|
||||||
|
};
|
||||||
|
if (task.delaysLoad) __dynLoadDelayingPending++;
|
||||||
|
__dynScriptQueue.push(task);
|
||||||
__processDynScriptQueue();
|
__processDynScriptQueue();
|
||||||
} else {
|
} else {
|
||||||
globalThis.__currentScriptNid = script._nid;
|
globalThis.__currentScriptNid = script._nid;
|
||||||
|
|||||||
@@ -605,6 +605,52 @@ impl ObscuraJsRuntime {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capture one immutable document slice as if its origin were the root
|
||||||
|
/// scroll position of a virtual viewport. This leaves the live page scroll
|
||||||
|
/// untouched while giving fixed and sticky descendants page-local paint
|
||||||
|
/// geometry, which paginated raster PDF export requires.
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
pub fn screenshot_prepared_region_at_scroll_with_backgrounds(
|
||||||
|
&self,
|
||||||
|
region: obscura_render::CaptureRegion,
|
||||||
|
root_scroll: (f32, f32),
|
||||||
|
paint_backgrounds: bool,
|
||||||
|
) -> Result<Vec<u8>, obscura_render::CaptureError> {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
with_sync_render_loading_disabled(&mut state, |state| {
|
||||||
|
ensure_resolved_scroll(state).ok_or(obscura_render::CaptureError::PaintFailed)?;
|
||||||
|
let ObscuraState {
|
||||||
|
dom,
|
||||||
|
prepared_render,
|
||||||
|
render_resources,
|
||||||
|
element_scroll_offsets,
|
||||||
|
..
|
||||||
|
} = state;
|
||||||
|
let dom = dom
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(obscura_render::CaptureError::PaintFailed)?;
|
||||||
|
let scroll = prepared_render
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(obscura_render::CaptureError::PaintFailed)?
|
||||||
|
.resolve_scroll_state_for_viewport(
|
||||||
|
dom,
|
||||||
|
root_scroll,
|
||||||
|
element_scroll_offsets,
|
||||||
|
(region.width, region.height),
|
||||||
|
);
|
||||||
|
obscura_render::screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||||
|
dom,
|
||||||
|
prepared_render
|
||||||
|
.as_mut()
|
||||||
|
.ok_or(obscura_render::CaptureError::PaintFailed)?,
|
||||||
|
render_resources,
|
||||||
|
&scroll,
|
||||||
|
region,
|
||||||
|
paint_backgrounds,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the retained layout's scrollable document size without changing
|
/// Return the retained layout's scrollable document size without changing
|
||||||
/// the live viewport or scroll position. PDF/full-document consumers use
|
/// the live viewport or scroll position. PDF/full-document consumers use
|
||||||
/// this to paginate document-space captures from the same geometry.
|
/// this to paginate document-space captures from the same geometry.
|
||||||
@@ -1492,6 +1538,19 @@ impl ObscuraJsRuntime {
|
|||||||
.is_pending_or_recent(std::time::Duration::from_millis(100))
|
.is_pending_or_recent(std::time::Duration::from_millis(100))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a connected dynamic script prepared before the document load
|
||||||
|
/// event still has fetch/evaluation/load-or-error work outstanding.
|
||||||
|
///
|
||||||
|
/// This intentionally excludes `import()` and scripts created by a load
|
||||||
|
/// handler. Those are ordinary post-load enhancement work and should only
|
||||||
|
/// be driven when an automation caller explicitly asks the page to settle.
|
||||||
|
pub fn has_pending_load_delaying_scripts(&mut self) -> bool {
|
||||||
|
self.evaluate("globalThis.__obscura_hasPendingLoadDelayingScripts?.() === true")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Generation of observable connected-document mutations. This excludes
|
/// Generation of observable connected-document mutations. This excludes
|
||||||
/// detached-tree construction and no-op writes, which cannot affect a
|
/// detached-tree construction and no-op writes, which cannot affect a
|
||||||
/// screenshot or DOM dump.
|
/// screenshot or DOM dump.
|
||||||
@@ -8379,6 +8438,8 @@ mod tests {
|
|||||||
fn dynamic_script_status_bridge_is_hidden_and_idle() {
|
fn dynamic_script_status_bridge_is_hidden_and_idle() {
|
||||||
let mut rt = setup_runtime("<html><body></body></html>");
|
let mut rt = setup_runtime("<html><body></body></html>");
|
||||||
assert!(!rt.has_pending_dynamic_scripts());
|
assert!(!rt.has_pending_dynamic_scripts());
|
||||||
|
assert!(!rt.has_pending_load_delaying_scripts());
|
||||||
|
assert_eq!(rt.next_pending_timeout_delay_ms(), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rt.evaluate("typeof __dynScriptBusy").unwrap(),
|
rt.evaluate("typeof __dynScriptBusy").unwrap(),
|
||||||
serde_json::json!("undefined")
|
serde_json::json!("undefined")
|
||||||
@@ -8397,6 +8458,27 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
serde_json::json!(false)
|
serde_json::json!(false)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate(
|
||||||
|
"Reflect.ownKeys(globalThis).includes('__obscura_hasPendingLoadDelayingScripts')"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate(
|
||||||
|
"Object.getOwnPropertyNames(globalThis).includes('__obscura_nextPendingTimeoutDelay')"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate(
|
||||||
|
"Reflect.ownKeys(globalThis).includes('__obscura_nextPendingTimeoutDelay')"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test for #147: a TypeError in one script must not poison
|
/// Regression test for #147: a TypeError in one script must not poison
|
||||||
|
|||||||
@@ -838,6 +838,14 @@ impl PreparedRender {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn clamp_scroll(&self, requested: (f32, f32)) -> (f32, f32) {
|
pub fn clamp_scroll(&self, requested: (f32, f32)) -> (f32, f32) {
|
||||||
|
self.clamp_scroll_for_viewport(requested, self.viewport)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_scroll_for_viewport(
|
||||||
|
&self,
|
||||||
|
requested: (f32, f32),
|
||||||
|
viewport: (f32, f32),
|
||||||
|
) -> (f32, f32) {
|
||||||
let clamp_axis = |requested: f32, content: f32, viewport: f32| {
|
let clamp_axis = |requested: f32, content: f32, viewport: f32| {
|
||||||
if requested.is_finite() {
|
if requested.is_finite() {
|
||||||
crate::quantize_scroll_value(requested, 1.0)
|
crate::quantize_scroll_value(requested, 1.0)
|
||||||
@@ -847,8 +855,8 @@ impl PreparedRender {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
clamp_axis(requested.0, self.content_size.0, self.viewport.0),
|
clamp_axis(requested.0, self.content_size.0, viewport.0),
|
||||||
clamp_axis(requested.1, self.content_size.1, self.viewport.1),
|
clamp_axis(requested.1, self.content_size.1, viewport.1),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,7 +878,26 @@ impl PreparedRender {
|
|||||||
requested_root: (f32, f32),
|
requested_root: (f32, f32),
|
||||||
requested_elements: &HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
requested_elements: &HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
) -> ResolvedScrollState {
|
) -> ResolvedScrollState {
|
||||||
let root = self.clamp_scroll(requested_root);
|
self.resolve_scroll_state_for_viewport(
|
||||||
|
tree,
|
||||||
|
requested_root,
|
||||||
|
requested_elements,
|
||||||
|
self.viewport,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve scroll-time movement for a virtual capture viewport without
|
||||||
|
/// mutating the live page scroll. Paginated raster consumers use each page
|
||||||
|
/// slice as its viewport so fixed/sticky content is positioned at that
|
||||||
|
/// page's origin, including a final slice shorter than the live viewport.
|
||||||
|
pub fn resolve_scroll_state_for_viewport(
|
||||||
|
&self,
|
||||||
|
tree: &DomTree,
|
||||||
|
requested_root: (f32, f32),
|
||||||
|
requested_elements: &HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
|
viewport: (f32, f32),
|
||||||
|
) -> ResolvedScrollState {
|
||||||
|
let root = self.clamp_scroll_for_viewport(requested_root, viewport);
|
||||||
let mut offsets = vec![(0.0, 0.0); self.scroll_tree.containers.len()];
|
let mut offsets = vec![(0.0, 0.0); self.scroll_tree.containers.len()];
|
||||||
offsets[0] = root;
|
offsets[0] = root;
|
||||||
for (index, container) in self.scroll_tree.containers.iter().enumerate().skip(1) {
|
for (index, container) in self.scroll_tree.containers.iter().enumerate().skip(1) {
|
||||||
@@ -915,7 +942,7 @@ impl PreparedRender {
|
|||||||
// Stage 1 keeps the established root sticky behavior. Nested sticky is
|
// Stage 1 keeps the established root sticky behavior. Nested sticky is
|
||||||
// intentionally absent from ScrollTree until its constraints are made
|
// intentionally absent from ScrollTree until its constraints are made
|
||||||
// scroll-container-relative in Stage 2.
|
// scroll-container-relative in Stage 2.
|
||||||
for (id, sticky) in self.sticky.translations(self.viewport, root) {
|
for (id, sticky) in self.sticky.translations(viewport, root) {
|
||||||
if let Some(movement) = node_movement.get_mut(id.index()) {
|
if let Some(movement) = node_movement.get_mut(id.index()) {
|
||||||
movement.0 += sticky.0;
|
movement.0 += sticky.0;
|
||||||
movement.1 += sticky.1;
|
movement.1 += sticky.1;
|
||||||
|
|||||||
Reference in new issue
Block a user