diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 41b0ba9..4352e11 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -1116,6 +1116,44 @@ impl Page { 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) { let scripts_started = std::time::Instant::now(); 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( + "", + "globalThis.__documentReadyState__ = 'interactive';", + ); + } + for scheduled in post_parse { if tokio::time::Instant::now() >= script_deadline { tracing::warn!("execute_scripts: deadline reached during post-parse scripts"); @@ -1817,81 +1865,33 @@ impl Page { } if let Some(js) = &mut self.js { - // Spec order: readyState -> interactive, fire DOMContentLoaded on both - // document and window, then readyState -> complete, fire load. - let _ = js.execute_script("", - "globalThis.__documentReadyState__ = 'interactive';\n\ - 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) {}\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) {}"); - } + // DOMContentLoaded follows parser/defer/module work, but async + // dynamic script elements do not gate it. They do remain in the + // document's load-event delay set, including scripts inserted by + // a DOMContentLoaded listener. + let _ = js.execute_script( + "", + "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) {}", + ); - 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::().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; - } - } + 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" + ); } - 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( + "", + "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(js) = self.js.as_mut() { @@ -3734,6 +3734,36 @@ mod tests { (format!("http://{}", address), request_rx) } + fn spawn_delayed_classic_script_server( + delay: std::time::Duration, + body: &'static str, + ) -> (String, std::sync::mpsc::Receiver) { + 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] fn external_scripts_require_a_successful_http_status() { assert!(script_response_is_executable(200)); @@ -4093,6 +4123,7 @@ mod tests { "#, ); page.execute_scripts().await; + page.settle_for_duration(500).await; assert_eq!( page.js .as_mut() @@ -4115,6 +4146,7 @@ mod tests { "#, ); page.execute_scripts().await; + page.settle_for_duration(500).await; assert_eq!( page.js .as_mut() @@ -4151,6 +4183,7 @@ mod tests { "#, ); page.execute_scripts().await; + page.settle_for_duration(500).await; assert_eq!( page.js .as_mut() @@ -4168,7 +4201,168 @@ mod tests { } #[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#""#, + ); + 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#""#, + ); + 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#"
Server shell
"#, + ); + + 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 _}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -4215,8 +4409,24 @@ mod tests { }); "#, ); + let started = std::time::Instant::now(); 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!( page.js .as_mut() @@ -4224,7 +4434,7 @@ mod tests { .evaluate("document.body.getAttribute('data-lazy-state')") .unwrap(), 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 { "#, ); page.execute_scripts().await; + page.settle_for_duration(500).await; assert_eq!( page.js .as_mut() diff --git a/crates/obscura-browser/src/pdf.rs b/crates/obscura-browser/src/pdf.rs index 4856441..aca5421 100644 --- a/crates/obscura-browser/src/pdf.rs +++ b/crates/obscura-browser/src/pdf.rs @@ -342,8 +342,9 @@ impl Page { let y = page_index as f32 * plan.css_page_height; let slice_height = (content_height - y).min(plan.css_page_height); 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), + (0.0, y), options.print_background, ) .map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?; @@ -533,6 +534,57 @@ impl io::Write for PdfWriter { mod tests { use super::*; + fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + 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 { + 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::() + .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, expected: [u8; 3]) -> bool { + actual + .0 + .into_iter() + .zip(expected) + .all(|(actual, expected)| (i16::from(actual) - i16::from(expected)).abs() <= 20) + } + #[test] fn options_reject_impossible_media_boxes() { 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#" +
+
+
+
+ "#, + ); + 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] 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, |_| { diff --git a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs index bc2f4b1..457f5f0 100644 --- a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs +++ b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs @@ -1,5 +1,5 @@ // 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::types::CdpRequest; @@ -100,7 +100,7 @@ async fn dynamic_external_scripts_execute_and_fire_load() { ) .await; - let result = cdp( + let at_load = cdp( &mut ctx, 2, "Runtime.evaluate", @@ -112,9 +112,31 @@ async fn dynamic_external_scripts_execute_and_fire_load() { ) .await; 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}"#, - "dynamic scripts must execute and fire load before navigation settles" + "dynamic scripts must execute and fire load before explicit settle completes" ); } diff --git a/crates/obscura-cdp/tests/js_fetch_emits_network_events.rs b/crates/obscura-cdp/tests/js_fetch_emits_network_events.rs index d9694e4..3d1941a 100644 --- a/crates/obscura-cdp/tests/js_fetch_emits_network_events.rs +++ b/crates/obscura-cdp/tests/js_fetch_emits_network_events.rs @@ -104,13 +104,15 @@ async fn js_fetch_emits_network_request_and_response() { let session_id = "session-1"; ctx.sessions.insert(session_id.to_string(), page_id.clone()); - // Navigate with waitUntil:load so the after-load fetch() runs and settles - // before the navigation emits its Network events. + // An ordinary fetch() is not load-delaying in Chromium: `load` may fire + // 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( &mut ctx, 1, "Page.navigate", - json!({"url": base, "waitUntil": "load"}), + json!({"url": base, "waitUntil": "networkidle0"}), session_id, ) .await; diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 019ed75..0138f87 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -16,7 +16,10 @@ '__obscura_objects', '__obscura_oid', '__obscura_ua', '__obscura_platform', '__obscura_ua_platform', '__obscura_ua_platform_version', '__obscura_stealth', '__obscura_markTrusted', - '__obscura_hw', '__obscura_mem', '__markParserScripts', + '__markParserScripts', '__obscura_hasPendingDynamicScripts', + '__obscura_hasPendingLoadDelayingScripts', + '__obscura_nextPendingTimeoutDelay', + '__obscura_hw', '__obscura_mem', '__documentReadyState__', '__currentUrl', // internal helpers (var-declared throughout the file) '__processDynScriptQueue', '_decodeDataScriptUrl', '_markNative', '_fpRand', '_fpNoise', @@ -199,6 +202,7 @@ let _fpSeed = 0; let __dynScriptQueue = []; let __dynScriptBusy = false; let __dynClassicPending = 0; +let __dynLoadDelayingPending = 0; Object.defineProperty(globalThis, '__obscura_hasPendingDynamicScripts', { value: function() { return __dynClassicPending > 0 || __dynScriptBusy || __dynScriptQueue.length > 0; @@ -207,6 +211,17 @@ Object.defineProperty(globalThis, '__obscura_hasPendingDynamicScripts', { enumerable: 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) { const comma = url.indexOf(','); if (!url.startsWith('data:') || comma < 5) { @@ -305,6 +320,11 @@ async function __runDynScriptTask(task) { } catch(e) { console.error('Dynamic script fetch error:', e.message); 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) { @@ -658,6 +678,19 @@ const _clearedTimers = new Set(); const _intervals = new Set(); const _nativeTimerIds = 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 d = Math.max(0, Number(delay) || 0); @@ -1327,6 +1360,12 @@ function __prepareInsertedScript(script) { pageOrigin, 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 // explicitly assigned `.async = false`. Keep that opt-out in insertion // order; default/async=true scripts fetch concurrently and execute as soon @@ -1342,14 +1381,17 @@ function __prepareInsertedScript(script) { } } else if (isModule) { const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code))); - __dynScriptQueue.push({ + const task = { url: dataUrl, isModule: true, nid: script._nid, prevNid, pageOrigin: "", dispatchEvent: (ev) => { try { script.dispatchEvent(ev); } catch(e) {} }, - }); + delaysLoad: globalThis.document?.readyState !== 'complete', + }; + if (task.delaysLoad) __dynLoadDelayingPending++; + __dynScriptQueue.push(task); __processDynScriptQueue(); } else { globalThis.__currentScriptNid = script._nid; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 60708f4..d97d365 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -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, 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 /// the live viewport or scroll position. PDF/full-document consumers use /// 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)) } + /// 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 /// detached-tree construction and no-op writes, which cannot affect a /// screenshot or DOM dump. @@ -8379,6 +8438,8 @@ mod tests { fn dynamic_script_status_bridge_is_hidden_and_idle() { let mut rt = setup_runtime(""); assert!(!rt.has_pending_dynamic_scripts()); + assert!(!rt.has_pending_load_delaying_scripts()); + assert_eq!(rt.next_pending_timeout_delay_ms(), None); assert_eq!( rt.evaluate("typeof __dynScriptBusy").unwrap(), serde_json::json!("undefined") @@ -8397,6 +8458,27 @@ mod tests { .unwrap(), 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 diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index f031747..132877d 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -838,6 +838,14 @@ impl PreparedRender { } 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| { if requested.is_finite() { 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.1, self.content_size.1, self.viewport.1), + clamp_axis(requested.0, self.content_size.0, viewport.0), + clamp_axis(requested.1, self.content_size.1, viewport.1), ) } @@ -870,7 +878,26 @@ impl PreparedRender { requested_root: (f32, f32), requested_elements: &HashMap, ) -> 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, + 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()]; offsets[0] = root; 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 // intentionally absent from ScrollTree until its constraints are made // 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()) { movement.0 += sticky.0; movement.1 += sticky.1;