diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index e79f7a7..cbedb28 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}; +use obscura_dom::{parse_html, DomTree, NodeData, NodeId}; use obscura_js::runtime::ObscuraJsRuntime; use obscura_net::{CallbackRegistry, ObscuraHttpClient, ObscuraNetError, RequestCallback, Response, ResponseCallback}; use url::Url; @@ -312,6 +312,127 @@ fn parse_import_url(stmt: &str) -> Option { Some(url) } +#[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(); @@ -500,7 +621,13 @@ impl Page { let all_links = match &self.js { Some(js) => { js.with_dom(|dom| { - let link_ids = dom.query_selector_all("link[rel=\"stylesheet\"]").unwrap_or_default(); + // `rel` is a space-separated token list. VitePress and + // other modern generators combine preload and stylesheet + // (`rel="preload stylesheet"`); exact-value matching + // silently skipped their entire CSS payload. + let link_ids = dom + .query_selector_all("link[rel~=\"stylesheet\"]") + .unwrap_or_default(); let mut links = Vec::new(); for lid in link_ids { if let Some(node) = dom.get_node(lid) { @@ -1091,12 +1218,17 @@ 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 { // Bounded against both async idle and synchronous microtask storms: // a plain tokio timeout cannot preempt a page that pins the thread // inside V8 (the real-world SPA hang), so settle drives the loop // through the watchdog-guarded path. let _ = js.run_event_loop_bounded(max_ms).await; + + if let Some(before) = hydration_snapshot { + let _ = restore_hydration_snapshot_if_needed(js, before); + } } } @@ -1399,7 +1531,11 @@ 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; @@ -2002,8 +2138,10 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool { #[cfg(test)] mod tests { use super::{ - parse_import_url, split_css_imports, truncate_on_char_boundary, url_matches_cdp_pattern, + parse_import_url, should_restore_hydration_snapshot, split_css_imports, + truncate_on_char_boundary, url_matches_cdp_pattern, HydrationStats, }; + use obscura_dom::parse_html; #[test] fn truncate_never_splits_a_multibyte_char() { @@ -2052,6 +2190,61 @@ mod tests { assert_eq!(stripped, css); } + #[test] + fn stylesheet_rel_token_selector_includes_preloaded_stylesheets() { + let dom = parse_html( + r#" + "#, + ); + let links = dom + .query_selector_all(r#"link[rel~="stylesheet"]"#) + .expect("valid selector"); + assert_eq!(links.len(), 1); + assert_eq!( + dom.get_node(links[0]) + .and_then(|node| node.get_attribute("href").map(str::to_owned)), + Some("app.css".to_string()) + ); + } + + #[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, + }, + )); + } + + #[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, + }, + )); + } + #[test] fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() { assert!(url_matches_cdp_pattern( diff --git a/crates/obscura-cdp/Cargo.toml b/crates/obscura-cdp/Cargo.toml index 07fc627..ee4ab2d 100644 --- a/crates/obscura-cdp/Cargo.toml +++ b/crates/obscura-cdp/Cargo.toml @@ -24,7 +24,6 @@ thiserror = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } futures-util = "0.3" -base64 = "0.22" obscura-net = { workspace = true } [target.'cfg(target_env = "gnu")'.dependencies] diff --git a/crates/obscura-dom/src/tree_sink.rs b/crates/obscura-dom/src/tree_sink.rs index 8720fd5..35952f9 100644 --- a/crates/obscura-dom/src/tree_sink.rs +++ b/crates/obscura-dom/src/tree_sink.rs @@ -250,10 +250,15 @@ pub fn parse_fragment(html: &str) -> DomTree { parse_fragment_with_context(html, context_name) } +/// Parse an HTML fragment using the supplied context element. +/// +/// The tree builder's insertion mode depends on this context. Treating every +/// `innerHTML` assignment as body content drops table-only elements such as a +/// top-level `` and mis-parses select/template fragments. Browsers instead +/// use the receiver element as the fragment parsing context. pub fn parse_fragment_with_context(html: &str, context_name: QualName) -> DomTree { use html5ever::tendril::TendrilSink; use html5ever::{parse_fragment, ParseOpts}; - let tree = DomTree::new(); parse_fragment(tree, ParseOpts::default(), context_name, vec![]) .from_utf8() @@ -327,4 +332,15 @@ mod tests { assert!(text.contains("Hello")); assert!(text.contains("World")); } + + #[test] + fn test_parse_fragment_uses_table_context() { + let context_name = QualName::new(None, ns!(html), local_name!("template")); + let tree = parse_fragment_with_context("cell", context_name); + let row = tree + .query_selector("tr") + .expect("valid selector") + .expect("template context preserves the row"); + assert_eq!(tree.text_content(row), "cell"); + } } diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 7146e97..11f77bf 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1542,9 +1542,14 @@ class Element extends Node { // Cache by node id so `.content` keeps a stable identity across reads — // frameworks stash the fragment and compare it later. if (!_cache.has(nid)) _cache.set(nid, new DocumentFragment(nid)); - return _cache.get(nid); + const content = _cache.get(nid); + content._fragmentContext = 'template'; + return content; + } + if (!this._templateContent) { + this._templateContent = document.createDocumentFragment(); + this._templateContent._fragmentContext = 'template'; } - if (!this._templateContent) this._templateContent = document.createDocumentFragment(); return this._templateContent; } if (tag === 'meta') return this.getAttribute('content') || ''; @@ -2774,6 +2779,13 @@ function _makeXPathResult(type, nodes) { class Document extends Node { get documentElement() { return _wrapEl(+_dom("document_element")); } + get children() { + const root = this.documentElement; + return HTMLCollection._from(root ? [root] : []); + } + get childElementCount() { return this.documentElement ? 1 : 0; } + get firstElementChild() { return this.documentElement; } + get lastElementChild() { return this.documentElement; } get head() { return this.querySelector("head"); } get body() { return this.querySelector("body"); } get doctype() { @@ -2850,6 +2862,7 @@ class Document extends Node { const el = _wrapEl(+_dom("create_element", t.toLowerCase())); if (el && t.toLowerCase() === 'template') { el._templateContent = this.createDocumentFragment(); + el._templateContent._fragmentContext = 'template'; } return el; } @@ -3258,7 +3271,14 @@ class DocumentFragment extends Node { get nodeType() { return 11; } get nodeName() { return "#document-fragment"; } get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; } - set innerHTML(v) { _dom("set_inner_html", this._nid, String(v ?? "")); } + set innerHTML(v) { + const html = String(v ?? ""); + if (this._fragmentContext) { + _dom("set_inner_html_context", this._nid, this._fragmentContext + "\0" + html); + } else { + _dom("set_inner_html", this._nid, html); + } + } querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); } querySelectorAll(s) { const ids = _domParse("query_selector_all_scoped", this._nid, s) || []; @@ -4736,11 +4756,13 @@ globalThis.CSSStyleSheet = class CSSStyleSheet { const idx = index ?? this._rules.length; this._rules.splice(idx, 0, { cssText: rule, type: 1 }); this.cssRules = this._rules; + _syncAdoptedStyleSheet(this); return idx; } deleteRule(index) { this._rules.splice(index, 1); this.cssRules = this._rules; + _syncAdoptedStyleSheet(this); } addRule(selector, style, index) { return this.insertRule(selector + '{' + style + '}', index); @@ -4749,17 +4771,73 @@ globalThis.CSSStyleSheet = class CSSStyleSheet { replace(text) { this._rules = [{ cssText: text, type: 1 }]; this.cssRules = this._rules; + _syncAdoptedStyleSheet(this); return Promise.resolve(this); } replaceSync(text) { this._rules = [{ cssText: text, type: 1 }]; this.cssRules = this._rules; + _syncAdoptedStyleSheet(this); } }; +function _syncAdoptedStyleSheet(sheet) { + const doc = globalThis.document; + if (doc && doc._adoptedStyleSheets?.includes(sheet)) { + _syncDocumentAdoptedStyles(doc); + } +} + +function _syncDocumentAdoptedStyles(doc) { + const sheets = doc._adoptedStyleSheets || []; + const nodes = doc._adoptedStyleNodes || (doc._adoptedStyleNodes = new Map()); + for (const [sheet, node] of Array.from(nodes.entries())) { + if (!sheets.includes(sheet)) { + node.remove(); + nodes.delete(sheet); + } + } + for (const sheet of sheets) { + if (!(sheet instanceof CSSStyleSheet)) continue; + let node = nodes.get(sheet); + if (!node || !node.parentNode) { + node = doc.createElement("style"); + node.setAttribute("data-obscura-adopted", ""); + (doc.head || doc.documentElement).appendChild(node); + nodes.set(sheet, node); + } + const css = Array.from(sheet.cssRules || [], rule => rule.cssText || "").join("\n"); + if (node.textContent !== css) node.textContent = css; + } +} + +function _makeAdoptedSheetList(doc, values) { + const target = Array.from(values || []); + return new Proxy(target, { + set(array, property, value) { + Reflect.set(array, property, value); + _syncDocumentAdoptedStyles(doc); + return true; + }, + deleteProperty(array, property) { + Reflect.deleteProperty(array, property); + _syncDocumentAdoptedStyles(doc); + return true; + }, + }); +} + Object.defineProperty(Document.prototype, 'adoptedStyleSheets', { - get() { return this._adoptedStyleSheets || []; }, - set(sheets) { this._adoptedStyleSheets = sheets; }, + get() { + if (!this._adoptedStyleSheets) { + this._adoptedStyleSheets = _makeAdoptedSheetList(this, []); + } + return this._adoptedStyleSheets; + }, + set(sheets) { + this._adoptedStyleSheets = _makeAdoptedSheetList(this, sheets); + _syncDocumentAdoptedStyles(this); + }, }); globalThis.__mutationObservers = []; @@ -6014,7 +6092,24 @@ globalThis.localStorage = _mkStore(); globalThis.sessionStorage = _mkStore(); globalThis.btoa = globalThis.btoa || ((s) => { const b = new TextEncoder().encode(s); const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=""; for(let i=0;i>2]+c[((a&3)<<4)|(bb>>4)]+(i+1>6)]:"=")+(i+2 { const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=[]; for(let i=0;i>4)); if(cc>=0)r.push(((b&15)<<4)|(cc>>2)); if(d>=0)r.push(((cc&3)<<6)|d);} return String.fromCharCode(...r); }); +globalThis.atob = globalThis.atob || ((s) => { + const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const r=[]; + s=String(s).replace(/[\t\n\f\r ]/g,""); + for(let i=0;i>4)); + if(cc>=0)r.push(((b&15)<<4)|(cc>>2)); + if(d>=0)r.push(((cc&3)<<6)|d); + } + // Spreading a large decoded payload into one call overflows V8's argument + // stack. Angular and other SSR frameworks routinely decode blobs large + // enough to hit that ceiling. + let out=""; + const chunk=0x8000; + for(let i=0;i { const c="ABCDEFGHIJKLMNOPQRSTUVWX forward() { this.go(1); }, }; })(); + +// Navigation API. New framework routers increasingly prefer `navigation` +// over popstate/history. Keep it backed by the functional History API above +// so both surfaces agree about the current URL and state. +(() => { + const listeners = Object.create(null); + const nav = { + addEventListener(type, callback) { + if (typeof callback !== "function") return; + (listeners[String(type)] ||= []).push(callback); + }, + removeEventListener(type, callback) { + const list = listeners[String(type)]; + if (!list) return; + const index = list.indexOf(callback); + if (index >= 0) list.splice(index, 1); + }, + dispatchEvent(event) { + if (!event || !event.type) return true; + const list = (listeners[String(event.type)] || []).slice(); + for (const callback of list) { + try { callback.call(nav, event); } catch (error) { console.error(error); } + } + return !event.defaultPrevented; + }, + }; + let serial = 0; + const makeEntry = () => { + const key = "obscura-" + serial; + const state = history.state; + return { + id: key, + key, + index: Math.max(0, history.length - 1), + sameDocument: true, + url: __currentUrl(), + getState() { return state; }, + addEventListener() {}, + removeEventListener() {}, + }; + }; + let entry = makeEntry(); + const changed = (from) => { + const old = from || entry; + serial++; + entry = makeEntry(); + try { + const ev = new Event("currententrychange"); + ev.from = old; + nav.dispatchEvent(ev); + } catch {} + return entry; + }; + Object.defineProperties(nav, { + currentEntry: { configurable: true, enumerable: true, get: () => entry }, + canGoBack: { configurable: true, enumerable: true, get: () => history.length > 1 }, + canGoForward: { configurable: true, enumerable: true, get: () => false }, + transition: { configurable: true, enumerable: true, get: () => null }, + activation: { configurable: true, enumerable: true, get: () => null }, + }); + nav.entries = () => [entry]; + nav.updateCurrentEntry = (options) => { + const old = entry; + const state = options && Object.prototype.hasOwnProperty.call(options, "state") + ? options.state : history.state; + history.replaceState(state, "", __currentUrl()); + return changed(old); + }; + nav.navigate = (url, options) => { + const old = entry; + const state = options && Object.prototype.hasOwnProperty.call(options, "state") + ? options.state : null; + if (options && options.history === "replace") history.replaceState(state, "", url); + else history.pushState(state, "", url); + const next = changed(old); + const done = Promise.resolve(next); + return { committed: done, finished: done }; + }; + nav.reload = () => { + const done = Promise.resolve(entry); + return { committed: done, finished: done }; + }; + nav.traverseTo = () => { + const done = Promise.resolve(entry); + return { committed: done, finished: done }; + }; + nav.back = () => { + history.back(); + const done = Promise.resolve(changed()); + return { committed: done, finished: done }; + }; + nav.forward = () => { + history.forward(); + const done = Promise.resolve(changed()); + return { committed: done, finished: done }; + }; + globalThis.navigation = nav; +})(); + globalThis.screenX = 0; globalThis.screenY = 0; globalThis.screenLeft = 0; globalThis.screenTop = 0; globalThis.pageXOffset = 0; globalThis.pageYOffset = 0; diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 7858119..f889932 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -449,6 +449,30 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str } "true".into() } + "set_inner_html_context" => { + let nid = match arg1.parse::() { + Ok(n) if n > 0 => n, + _ => return "false".into(), + }; + let target = NodeId::new(nid); + let (context, html) = arg2 + .split_once('\0') + .unwrap_or(("body", arg2.as_str())); + for child in dom.children(target) { + dom.detach(child); + } + if !html.is_empty() { + let context_name = html5ever::QualName::new( + None, + html5ever::ns!(html), + html5ever::LocalName::from(context.to_ascii_lowercase()), + ); + let fragment = obscura_dom::parse_fragment_with_context(html, context_name); + let import_root = fragment.fragment_root(); + dom.import_children_from(target, &fragment, import_root); + } + "true".into() + } "set_text_content" => { let nid = arg1.parse::().unwrap_or(0); dom.with_node_mut(NodeId::new(nid), |n| { diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 953b0fc..e4fae29 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1015,6 +1015,40 @@ 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); + } + #[cfg(feature = "render")] + { + state.layout_cache = None; + } + true + } + /// Absolute URLs the page requested via fetch()/XHR, in request order /// (issue #301). Backs `--dump assets`. pub fn fetched_urls(&self) -> Vec { @@ -2657,6 +2691,109 @@ mod tests { assert!(html.as_str().unwrap().contains("

")); } + #[test] + fn template_inner_html_preserves_table_fragments() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#" + const template = document.createElement('template'); + template.innerHTML = 'firstsecond'; + const clone = template.content.firstChild.cloneNode(true); + return [ + clone.tagName, + clone.firstElementChild.tagName, + clone.firstElementChild.children.length, + clone.textContent, + ]; + "#, + ) + .unwrap(); + assert_eq!(result, serde_json::json!(["TR", "TD", 0, "firstsecond"])); + } + + #[test] + fn document_exposes_parent_node_element_children_api() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + "return [document.firstElementChild === document.documentElement,\ + document.lastElementChild === document.documentElement,\ + document.children.length, document.childElementCount];", + ) + .unwrap(); + assert_eq!(result, serde_json::json!([true, true, 1, 1])); + } + + #[test] + fn atob_decodes_large_payload_without_argument_stack_overflow() { + let mut rt = setup_runtime(""); + // 60k four-character groups decode to 180k bytes, comfortably above + // V8's maximum argument count for a single fromCharCode(...bytes). + let encoded = "QUFB".repeat(60_000); + let result = rt + .evaluate(&format!("atob('{}').length", encoded)) + .unwrap(); + assert_eq!(result.as_f64().unwrap() as usize, 180_000); + } + + #[test] + fn navigation_api_updates_current_entry_state() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#" + (() => { + navigation.updateCurrentEntry({state: {route: 'home'}}); + const first = navigation.currentEntry; + navigation.navigate('/docs', {state: {route: 'docs'}}); + return [ + typeof navigation.updateCurrentEntry, + first.getState().route, + navigation.currentEntry.getState().route, + navigation.currentEntry.url, + ]; + })() + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([ + "function", + "home", + "docs", + "http://example.com/docs" + ]) + ); + } + + #[test] + fn adopted_stylesheets_materialize_into_the_document() { + let mut rt = setup_runtime("

"); + let result = rt + .evaluate( + r#" + (() => { + const sheet = new CSSStyleSheet(); + document.adoptedStyleSheets.push(sheet); + sheet.insertRule('.card { display: flex; color: red; }', 0); + const node = document.querySelector('style[data-obscura-adopted]'); + const inserted = node.textContent; + sheet.deleteRule(0); + return [ + document.adoptedStyleSheets.length, + document.querySelectorAll('style[data-obscura-adopted]').length, + inserted.includes('display: flex'), + node.textContent, + ]; + })() + "#, + ) + .unwrap(); + assert_eq!(result, serde_json::json!([1, 1, true, ""])); + } + #[test] fn test_script_execution() { let mut rt = setup_runtime("
  • A
  • B
"); diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index a0dd6fd..de69c95 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -466,6 +466,11 @@ pub fn layout_dom_with_images( /// taffy layout. Not a CSS-inherited property; it is recomputed to /// the element's own content width for its children. cb_width: f32, + /// Whether the containing block has a definite height. Percentage + /// heights in ordinary flow compute to auto when this is false; + /// resolving them against a synthetic zero height collapses + /// content-heavy modern UI wrappers such as code editors. + cb_height_definite: bool, } impl Default for Inherited { fn default() -> Self { @@ -487,6 +492,7 @@ pub fn layout_dom_with_images( border_collapse: false, table_vertical_align: None, cb_width: 0.0, + cb_height_definite: false, } } } @@ -513,11 +519,13 @@ pub fn layout_dom_with_images( // i.e. the viewport width. let mut root_inh = Inherited::default(); root_inh.cb_width = viewport.0; + root_inh.cb_height_definite = true; let mut queue = vec![(root_id, root_inh)]; while let Some((id, mut inh)) = queue.pop() { // Default the child containing-block width to this element's own // (updated to its content width inside the block below). let mut child_cb_width = inh.cb_width; + let mut child_cb_height_definite = false; if let Some(style) = styles.get_mut(&id) { match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color } // Resolve a relative font-size against the PARENT (em/%) or @@ -575,6 +583,16 @@ pub fn layout_dom_with_images( style.max_width = style.max_width.resolve(em_px, root_fs, vw, vh); style.max_height = style.max_height.resolve(em_px, root_fs, vw, vh); style.flex_basis = style.flex_basis.resolve(em_px, root_fs, vw, vh); + if matches!(style.height, crate::Dimension::Percent(_)) + && !inh.cb_height_definite + && !matches!(style.position, Some(taffy::Position::Absolute)) + { + style.height = crate::Dimension::Auto; + } + child_cb_height_definite = matches!( + style.height, + crate::Dimension::Px(_) | crate::Dimension::Percent(_) + ); for i in style.inset.iter_mut() { if let Some(d) = i { *i = Some(d.resolve(em_px, root_fs, vw, vh)); @@ -748,6 +766,7 @@ pub fn layout_dom_with_images( }; } inh.cb_width = child_cb_width; + inh.cb_height_definite = child_cb_height_definite; for cid in tree.children(id).into_iter().rev() { queue.push((cid, inh.clone())); } @@ -847,29 +866,70 @@ pub fn layout_dom_with_images( resolve_grid_areas(tree, root_id, &mut styles); - // Blockify grid items (CSS Display 3): a direct child of a grid - // container is blockified, so an inline ``/`` into one word per cell). Only grid is done - // here, not flex: obscura also uses flex-column as the UA stand-in for - // block containers like ``, whose inline text must stay inline. - let grid_parents: Vec = styles + // Blockify flex/grid items (CSS Display 3): a direct inline child of a + // genuine flex or grid container gets a blockified outer display while + // retaining its own inner formatting context. Without the flex half, + // `
...
` collapses the code item and + // navigation bars treat direct `
` children as wrapping inline + // containers instead of flex items. + // + // `internal_flex_container` is deliberately excluded: table cells use + // a flex column only as our block-layout stand-in, and their ordinary + // inline text must continue to form an inline formatting context. + let item_parents: Vec = styles .iter() - .filter(|(_, s)| s.display == crate::Display::Grid) + .filter(|(_, s)| { + s.display == crate::Display::Grid + || (s.display == crate::Display::Flex + && !s.internal_flex_container) + }) .map(|(&id, _)| id) .collect(); - for pid in grid_parents { + for pid in item_parents { for cid in tree.children(pid) { if let Some(cs) = styles.get_mut(&cid) { - if cs.display == crate::Display::Inline && !cs.is_inline_block { + if cs.display == crate::Display::Inline { cs.display = crate::Display::Block; } } } } + // In an auto-height column flex container, a zero flex basis still + // participates in intrinsic main-size calculation through the item's + // automatic minimum size. Taffy otherwise treats `flex: 1 1` plus + // overflow:auto as an unconditional zero contribution, collapsing the + // entire ancestor chain even when the item contains many lines. Use + // an auto basis for this indefinite-size phase; definite-height flex + // containers keep the authored zero basis and normal free-space math. + let intrinsic_column_flex_parents: Vec = styles + .iter() + .filter(|(_, style)| { + style.display == crate::Display::Flex + && style.flex_direction == Some(taffy::FlexDirection::Column) + && style.height == crate::Dimension::Auto + }) + .map(|(&id, _)| id) + .collect(); + for parent in intrinsic_column_flex_parents { + for child in tree.children(parent) { + let Some(style) = styles.get_mut(&child) else { + continue; + }; + let zero_basis = matches!( + style.flex_basis, + crate::Dimension::Px(value) | crate::Dimension::Percent(value) + if value == 0.0 + ); + if zero_basis + && style.height == crate::Dimension::Auto + && style.min_height == crate::Dimension::Auto + { + style.flex_basis = crate::Dimension::Auto; + } + } + } + // CSS Display blockification: absolute/fixed boxes and floats compute // an inline outside display to block. This affects how their own mixed // children form lines and blocks; they remain shrink-to-fit where the @@ -3179,6 +3239,17 @@ fn build( has_inline_content(tree, id, styles) || style.before_content.is_some() || style.after_content.is_some(); let mut dom_children = tree.children(id); + let internal_mixed_block_flow = style.internal_flex_container + && dom_children.iter().any(|cid| { + styles.get(cid).map_or(false, |child| { + child.display == crate::Display::Block + && child.float.is_none() + && !matches!( + child.position, + Some(taffy::Position::Absolute) + ) + }) + }); // In flex and grid formatting contexts, collapsible whitespace-only text // between items does not generate an anonymous item. Taffy places each // stray whitespace leaf in the item sequence, shifting every real child. @@ -3188,7 +3259,9 @@ fn build( // Flatten display:contents first because its children participate as direct // flex/grid items. Otherwise formatting whitespace inside the transparent // wrapper would survive this filter and become an item in `build_any`. - if matches!(style.display, crate::Display::Flex | crate::Display::Grid) { + if matches!(style.display, crate::Display::Flex | crate::Display::Grid) + && !internal_mixed_block_flow + { let mut flat = Vec::new(); flatten_contents_children(tree, &dom_children, styles, &mut flat); dom_children = flat; @@ -3219,8 +3292,7 @@ fn build( // approximation corrupts flex sizing and percentage-margin placement. let has_float_child = style.display == crate::Display::Block && dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false)); - let has_in_flow_block_child = style.display == crate::Display::Block - && dom_children.iter().any(|cid| { + let has_in_flow_block_child = dom_children.iter().any(|cid| { styles.get(cid).map_or(false, |child| { child.display == crate::Display::Block && child.float.is_none() @@ -3240,6 +3312,10 @@ fn build( && !matches!(child.position, Some(taffy::Position::Absolute)) }) }); + let is_native_table_cell = node.as_element().map_or(false, |element| { + matches!(element.local.as_ref(), "td" | "th") + && style.internal_flex_container + }); // `text-align` affects inline content, never the used width or placement // of an in-flow block child. `to_taffy_style` promotes a centered/right @@ -3265,7 +3341,11 @@ fn build( // block children (e.g. a `position:relative` hero wrapper whose only // child is out-of-flow) to width 0 and let text and blocks share lines. // Floats still take the legacy zone path below. - if style.display == crate::Display::Block && has_inline_ish_content && !has_float_child { + if (style.display == crate::Display::Block + || (is_native_table_cell && has_in_flow_block_child)) + && has_inline_ish_content + && !has_float_child + { return build_mixed_block(tree, id, style, taffy_style, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles); } @@ -3496,6 +3576,14 @@ fn build_mixed_block( child_ids.extend(built); } Seg::Run(run) => { + let has_text_strut = run.iter().any(|&cid| { + tree.get_node(cid).map_or(false, |node| { + matches!( + node.data, + obscura_dom::tree::NodeData::Text { .. } + ) + }) + }); // Collapsible source formatting at the start/end of an inline // run does not create line width. Preserve whitespace between // inline siblings, but trim indentation adjacent to block @@ -3541,18 +3629,27 @@ fn build_mixed_block( // Whitespace-only run between blocks: no anonymous box. continue; } - let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &atoms).ok()?; + let wrapper = taffy_tree + .new_with_children( + run_wrapper_style(style, has_text_strut), + &atoms, + ) + .ok()?; child_ids.push(wrapper); } } } // Pseudo content that found no adjacent run to join. if before_pending { - let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &before_leaves).ok()?; + let wrapper = taffy_tree + .new_with_children(run_wrapper_style(style, true), &before_leaves) + .ok()?; child_ids.insert(0, wrapper); } if after_pending { - let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &after_leaves).ok()?; + let wrapper = taffy_tree + .new_with_children(run_wrapper_style(style, true), &after_leaves) + .ok()?; child_ids.push(wrapper); } for cid in out_of_flow { @@ -3601,12 +3698,20 @@ fn run_leaf_style() -> taffy::Style { /// context. The parent's `text-align` moves the run's line content via /// justify-content, exactly as the old whole-container /// promotion did, but scoped to the run so sibling blocks stay full width. -fn run_wrapper_style(parent: &crate::LayoutStyle) -> taffy::Style { +fn run_wrapper_style( + parent: &crate::LayoutStyle, + has_text_strut: bool, +) -> taffy::Style { let justify = match parent.text_align { Some(taffy::AlignItems::FLEX_END) => Some(taffy::JustifyContent::FLEX_END), Some(taffy::AlignItems::CENTER) => Some(taffy::JustifyContent::CENTER), _ => None, }; + let line_height = if has_text_strut { + crate::inline::used_line_height(parent).max(0.0) + } else { + 0.0 + }; taffy::Style { display: taffy::style::Display::Flex, flex_direction: taffy::FlexDirection::Row, @@ -3614,6 +3719,12 @@ fn run_wrapper_style(parent: &crate::LayoutStyle) -> taffy::Style { align_items: Some(taffy::AlignItems::FLEX_START), justify_content: justify, size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() }, + // Every CSS line box starts with the parent's font/line-height strut, + // even when its only atomic inline is shorter (or zero-sized). + min_size: taffy::Size { + width: taffy::style::Dimension::auto(), + height: taffy::style::Dimension::length(line_height), + }, ..Default::default() } } diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index a2f6e69..9c5b342 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -182,6 +182,16 @@ pub fn ua_style(tag: &str) -> LayoutStyle { style.font_weight = Some("bold".to_string()); } } else if tag == "img" || tag == "figure" { + if tag == "img" { + // Images are inline-level replaced elements by default. Model the + // atomic inner box with the same inline-block marker used by + // native controls, so an icon between text fragments participates + // in their line instead of splitting the parent into block runs. + // An authored display declaration clears this marker in the + // cascade before applying its requested display. + style.display = Display::Inline; + style.is_inline_block = true; + } // Near-universal reset: images fit their container instead of // overflowing. Wikipedia (and most sites) set `img{max-width:100%}`; // making it a UA default prevents a fixed-width thumbnail (e.g. a @@ -2857,6 +2867,14 @@ mod tests { let invalid = compute_style("td", Some("display:bogus")); assert!(invalid.internal_flex_container); + + let native_image = compute_style("img", None); + assert_eq!(native_image.display, Display::Inline); + assert!(native_image.is_inline_block); + + let block_image = compute_style("img", Some("display:block")); + assert_eq!(block_image.display, Display::Block); + assert!(!block_image.is_inline_block); } #[test] diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index d72496b..b39a265 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -701,6 +701,21 @@ fn inline_block_flex_items_keep_block_inner_flow() { ); } +#[test] +fn percentage_height_under_indefinite_parent_uses_content_height() { + let tree = parse_html(include_str!( + "../../../render-repros/percentage-height-indefinite-parent.html" + )); + let layout = layout_dom(&tree, (900.0, 1000.0)); + for id in ["wrapper", "stack", "editor", "pre", "code"] { + let rect = layout.rects[&tree.get_element_by_id(id).unwrap()]; + assert!( + rect.height >= 24.0, + "{id} collapsed despite its indefinite percentage height: {rect:?}" + ); + } +} + #[test] fn item_self_alignment_places_flex_and_grid_items() { let tree = parse_html(include_str!("../../../render-repros/item-self-alignment.html")); diff --git a/render-repros/checks.json b/render-repros/checks.json index f90c218..7b94364 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -121,6 +121,14 @@ {"name": "inline block list shrink wraps and stacks children", "color": "#1971c2", "x": 525, "y": 0, "width": 90, "height": 60}, {"name": "table stand-in does not blockify inline-block list", "color": "#c2255c", "x": 0, "y": 140, "width": 90, "height": 20} ], + "inline-replaced-flow": [ + {"name": "default image stays in its inline formatting context", "color": "#1971c2", "x": 60, "y": 0, "width": 40, "height": 20}, + {"name": "following inline content remains on the image line", "color": "#e8590c", "x": 100, "y": 0, "width": 70, "height": 20}, + {"name": "authored block image still starts a block row", "color": "#1971c2", "x": 0, "y": 80, "width": 40, "height": 20}, + {"name": "content after authored block image follows below", "color": "#e8590c", "x": 0, "y": 100, "width": 70, "height": 20}, + {"name": "atomic inline run retains its line-height strut", "color": "#7048e8", "x": 0, "y": 178, "width": 300, "height": 4}, + {"name": "mixed cell keeps block child full width and centered", "color": "#2b8a3e", "x": 100, "y": 182, "width": 100, "height": 20} + ], "item-self-alignment": [ {"name": "flex item centers itself on cross axis", "color": "#087f5b", "x": 0, "y": 40, "width": 40, "height": 20}, {"name": "flex item aligns itself to cross end", "color": "#1971c2", "x": 40, "y": 70, "width": 50, "height": 30}, diff --git a/render-repros/inline-replaced-flow.html b/render-repros/inline-replaced-flow.html new file mode 100644 index 0000000..8133f5a --- /dev/null +++ b/render-repros/inline-replaced-flow.html @@ -0,0 +1,78 @@ + + + + + inline replaced flow + + + +
+
+ + + + +
+ +
+
+
+ + diff --git a/render-repros/percentage-height-indefinite-parent.html b/render-repros/percentage-height-indefinite-parent.html new file mode 100644 index 0000000..a9c9ab9 --- /dev/null +++ b/render-repros/percentage-height-indefinite-parent.html @@ -0,0 +1,19 @@ + + + +
+
+
+
+
content must give an indefinite percentage-height chain its height
+
+
+
+