Align load readiness and paginated fixed content

This commit is contained in:
SGavrl committed 2026-08-04 06:21:41 +00:00
1 parent 03f506b01a
commit 1f8a4f0baa
7 files changed
+610 -90

No files matched your search

+286 -75
View File
@@ -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>",
if (typeof window.onload === 'function') { try { window.onload(); } catch(e) {} }\n\ "try { document.dispatchEvent(new Event('DOMContentLoaded', {bubbles:false,cancelable:false})); } catch(e) {}\n\
globalThis.__documentReadyState__ = 'complete';\n\ try { window.dispatchEvent(new Event('DOMContentLoaded', {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 load_blockers_finished =
let dynamic_settle_ms = std::env::var("OBSCURA_DYNAMIC_SCRIPT_SETTLE_MS") Self::drive_load_delaying_scripts(js, script_deadline).await;
.ok() if !load_blockers_finished {
.and_then(|s| s.parse::<u64>().ok()) tracing::warn!(
.unwrap_or(3_000) "script deadline reached with load-delaying dynamic scripts still pending"
.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);
// 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\
try { window.dispatchEvent(new Event('load', {bubbles:false,cancelable:false})); } catch(e) {}",
);
} }
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()
+135 -1
View File
@@ -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;
+45 -3
View File
@@ -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;
+82
View File
@@ -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
+31 -4
View File
@@ -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;