diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index a061f63..04e7849 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use obscura_dom::{parse_html, DomTree, NodeData, NodeId}; +use obscura_dom::{parse_html, DomTree}; use obscura_js::runtime::ObscuraJsRuntime; use obscura_net::{CallbackRegistry, ObscuraHttpClient, ObscuraNetError, RequestCallback, Response, ResponseCallback}; use url::Url; @@ -512,127 +512,6 @@ fn linked_stylesheet_requests(dom: &DomTree) -> Vec<(usize, String)> { links } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct HydrationStats { - elements: usize, - text_chars: usize, -} - -struct HydrationSnapshot { - body_html: String, - stats: HydrationStats, -} - -fn capture_hydration_snapshot(js: &ObscuraJsRuntime) -> Option { - js.with_dom(|dom| { - let body = dom.query_selector("body").ok().flatten()?; - Some(HydrationSnapshot { - body_html: dom.inner_html(body), - stats: hydration_stats(dom, body), - }) - }) - .flatten() -} - -fn capture_hydration_stats(js: &ObscuraJsRuntime) -> Option { - js.with_dom(|dom| { - let body = dom.query_selector("body").ok().flatten()?; - Some(hydration_stats(dom, body)) - }) - .flatten() -} - -fn hydration_stats(dom: &DomTree, body: NodeId) -> HydrationStats { - let mut elements = 0; - let mut text_chars = 0; - - for node_id in dom.descendants(body) { - let Some(node) = dom.get_node(node_id) else { - continue; - }; - match &node.data { - NodeData::Element { name, .. } => { - if !matches!( - name.local.as_ref(), - "script" | "style" | "link" | "meta" | "template" | "noscript" - ) { - elements += 1; - } - } - NodeData::Text { contents } => { - if !has_non_content_ancestor(dom, node.parent, body) { - text_chars += contents.chars().filter(|c| !c.is_whitespace()).count(); - } - } - _ => {} - } - } - - HydrationStats { - elements, - text_chars, - } -} - -fn has_non_content_ancestor( - dom: &DomTree, - mut current: Option, - body: NodeId, -) -> bool { - while let Some(node_id) = current { - if node_id == body { - return false; - } - let Some(node) = dom.get_node(node_id) else { - return false; - }; - if node - .as_element() - .is_some_and(|name| matches!(name.local.as_ref(), "script" | "style" | "template" | "noscript")) - { - return true; - } - current = node.parent; - } - false -} - -/// A server-rendered body should only be restored for an unmistakable wipe: -/// a substantial document lost at least seven eighths of its real elements -/// and visible text, leaving almost no content. This deliberately does not -/// second-guess ordinary framework rerenders or client-only application shells. -fn should_restore_hydration_snapshot( - before: HydrationStats, - after: HydrationStats, -) -> bool { - before.elements >= 40 - && before.text_chars >= 200 - && after.elements <= 12 - && after.text_chars <= 128 - && after.elements.saturating_mul(8) < before.elements - && after.text_chars.saturating_mul(8) < before.text_chars -} - -fn restore_hydration_snapshot_if_needed( - js: &ObscuraJsRuntime, - before: HydrationSnapshot, -) -> bool { - let Some(after) = capture_hydration_stats(js) else { - return false; - }; - if !should_restore_hydration_snapshot(before.stats, after) { - return false; - } - tracing::warn!( - before_elements = before.stats.elements, - after_elements = after.elements, - before_text_chars = before.stats.text_chars, - after_text_chars = after.text_chars, - "restoring server-rendered body after catastrophic hydration wipe" - ); - js.replace_body_inner_html(&before.body_html) -} - impl Page { pub fn new(id: String, context: Arc) -> Self { let http_client = context.http_client.clone(); @@ -1550,7 +1429,6 @@ impl Page { if max_ms == 0 { return; } - let hydration_snapshot = self.js.as_ref().and_then(capture_hydration_snapshot); if let Some(js) = &mut self.js { if std::env::var_os("OBSCURA_STRICT_SETTLE").is_some() { // Paired browser measurements need the same wall-clock interval @@ -1570,10 +1448,6 @@ impl Page { // the thread inside V8, so settle uses the watchdog path. let _ = js.run_event_loop_bounded(max_ms).await; } - - if let Some(before) = hydration_snapshot { - let _ = restore_hydration_snapshot_if_needed(js, before); - } } } @@ -1878,11 +1752,7 @@ impl Page { // page.click() handlers were no-ops. Now scripts run regardless // of waitUntil and DCL means "DOM parsed AND scripts executed". self.fetch_stylesheets().await; - let hydration_snapshot = self.js.as_ref().and_then(capture_hydration_snapshot); self.execute_scripts().await; - if let (Some(js), Some(before)) = (&self.js, hydration_snapshot) { - let _ = restore_hydration_snapshot_if_needed(js, before); - } self.lifecycle = LifecycleState::DomContentLoaded; @@ -2515,12 +2385,77 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool { mod tests { use super::{ linked_stylesheet_requests, materialize_linked_stylesheet_script, parse_import_url, - rebase_css_urls, should_restore_hydration_snapshot, split_css_imports, + rebase_css_urls, split_css_imports, script_response_is_executable, truncate_on_char_boundary, url_matches_cdp_pattern, - HydrationStats, }; + use base64::Engine as _; use obscura_dom::parse_html; + fn client_replacement_page(name: &str, deferred: bool) -> super::Page { + let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( + name.to_string(), None, false, None, None, true, + )); + let mut page = super::Page::new(name.to_string(), context); + let server_content = (0..45) + .map(|index| format!("

server content item {index} with enough text

")) + .collect::(); + let start = if deferred { + "window.addEventListener('mount-client', () => setTimeout(mountClient, 0));" + } else { + "mountClient();" + }; + let html = format!( + r#"
{server_content}
"#, + ); + let encoded = base64::engine::general_purpose::STANDARD.encode(html); + page.url = Some( + url::Url::parse(&format!("data:text/html;base64,{encoded}")) + .expect("data URL"), + ); + page + } + + fn assert_client_replacement_survived(page: &mut super::Page) { + let state = page + .js + .as_mut() + .expect("page runtime") + .evaluate( + r#" + var clientReplacementCheck = true; + const button = document.getElementById('client'); + if (button) button.dispatchEvent(new Event('click')); + return { + staleServerContent: !!document.getElementById('ssr'), + clientPresent: !!button, + clientText: button ? button.textContent : null, + clicks: button ? button.getAttribute('data-clicks') : null, + bodyElements: document.querySelectorAll('body *').length + }; + "#, + ) + .expect("inspect client replacement"); + assert_eq!( + state, + serde_json::json!({ + "staleServerContent": false, + "clientPresent": true, + "clientText": "Client view", + "clicks": "1", + "bodyElements": 1, + }), + ); + } + fn spawn_parser_import_map_server( expected_requests: usize, ) -> (String, std::sync::mpsc::Receiver) { @@ -3127,42 +3062,35 @@ mod tests { ); } - #[test] - fn hydration_recovery_requires_a_catastrophic_content_wipe() { - assert!(should_restore_hydration_snapshot( - HydrationStats { - elements: 520, - text_chars: 8_000, - }, - HydrationStats { - elements: 4, - text_chars: 0, - }, - )); + #[tokio::test(flavor = "current_thread")] + async fn parser_script_body_replacement_survives_navigation() { + let mut page = client_replacement_page("parser-client-replacement", false); + let target = page.url_string(); + + page.navigate(&target).await.expect("navigate replacement page"); + + assert_client_replacement_survived(&mut page); } - #[test] - fn hydration_recovery_does_not_override_normal_rerenders() { - assert!(!should_restore_hydration_snapshot( - HydrationStats { - elements: 520, - text_chars: 8_000, - }, - HydrationStats { - elements: 180, - text_chars: 3_000, - }, - )); - assert!(!should_restore_hydration_snapshot( - HydrationStats { - elements: 12, - text_chars: 80, - }, - HydrationStats { - elements: 1, - text_chars: 0, - }, - )); + #[tokio::test(flavor = "current_thread")] + async fn timer_body_replacement_survives_settle() { + let mut page = client_replacement_page("timer-client-replacement", true); + let target = page.url_string(); + page.navigate(&target).await.expect("navigate deferred replacement page"); + + let before_timer = page + .js + .as_mut() + .expect("page runtime") + .evaluate( + "var scheduleClientReplacement = true; window.dispatchEvent(new Event('mount-client')); return !!document.getElementById('ssr');", + ) + .expect("schedule client replacement"); + assert_eq!(before_timer, serde_json::json!(true)); + + page.settle(100).await; + + assert_client_replacement_survived(&mut page); } #[test] diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 632b629..4cbd4ac 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -14,8 +14,7 @@ use crate::module_loader::ObscuraModuleLoader; #[cfg(feature = "render")] use crate::ops::{clamp_scroll_offset, document_base_url, ensure_prepared_render}; use crate::ops::{ - build_extension, mark_script_subtree_started, node_is_script, ObscuraState, - StoredNetworkResponseBody, + build_extension, node_is_script, ObscuraState, StoredNetworkResponseBody, }; static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); @@ -1246,43 +1245,6 @@ impl ObscuraJsRuntime { state.dom.as_ref().map(f) } - /// Replace the body's children while preserving the body node itself. - /// - /// Keeping the document/body node IDs stable matters because JavaScript - /// wrappers cache those IDs. This is used by the browser's narrowly - /// guarded hydration recovery path after a framework catastrophically - /// clears an otherwise complete server-rendered document. - pub fn replace_body_inner_html(&self, html: &str) -> bool { - #[allow(unused_mut)] - let mut state = self.state.borrow_mut(); - let Some(dom) = state.dom.as_ref() else { - return false; - }; - let Ok(Some(body)) = dom.query_selector("body") else { - return false; - }; - - for child in dom.children(body) { - // Detach instead of recycling the node slot: wrappers held by a - // failed hydration attempt must not start referring to a newly - // restored, unrelated node that reused the same ID. - dom.detach(child); - } - if !html.is_empty() { - let fragment = obscura_dom::parse_fragment(html); - let root = fragment.fragment_root(); - dom.import_children_from(body, &fragment, root); - for child in dom.children(body) { - mark_script_subtree_started(&state, child); - } - } - #[cfg(feature = "render")] - { - state.prepared_render = None; - } - true - } - /// Absolute URLs the page requested via fetch()/XHR, in request order /// (issue #301). Backs `--dump assets`. pub fn fetched_urls(&self) -> Vec {