render: support modern framework content and geometry

This commit is contained in:
SGavrl committed 2026-07-26 10:50:31 +00:00
1 parent 9acd74dbea
commit 228a5723ce
12 files changed
+843 -31

No files matched your search

+196 -3
View File
@@ -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<String> {
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<HydrationSnapshot> {
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<HydrationStats> {
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<NodeId>,
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<BrowserContext>) -> 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#"<link rel="preload stylesheet" href="app.css">
<link rel="preload" href="font.woff2">"#,
);
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(
-1
View File
@@ -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]
+17 -1
View File
@@ -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 `<tr>` 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("<tr><td>cell</td></tr>", context_name);
let row = tree
.query_selector("tr")
.expect("valid selector")
.expect("template context preserves the row");
assert_eq!(tree.text_content(row), "cell");
}
}
+200 -6
View File
@@ -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<b.length;i+=3){const a=b[i],bb=b[i+1]??0,cc=b[i+2]??0; r+=c[a>>2]+c[((a&3)<<4)|(bb>>4)]+(i+1<b.length?c[((bb&15)<<2)|(cc>>6)]:"=")+(i+2<b.length?c[cc&63]:"=");} return r; });
globalThis.atob = globalThis.atob || ((s) => { const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=[]; for(let i=0;i<s.length;i+=4){const a=c.indexOf(s[i]),b=c.indexOf(s[i+1]),cc=c.indexOf(s[i+2]),d=c.indexOf(s[i+3]); r.push((a<<2)|(b>>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<s.length;i+=4){
const a=c.indexOf(s[i]),b=c.indexOf(s[i+1]),cc=c.indexOf(s[i+2]),d=c.indexOf(s[i+3]);
r.push((a<<2)|(b>>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<r.length;i+=chunk) out+=String.fromCharCode(...r.slice(i,i+chunk));
return out;
});
// Functional History API. The earlier stub returned constant state and was a
// no-op on push/replace, so any SPA that tried to update its URL (Next.js
@@ -6093,6 +6188,105 @@ globalThis.atob = globalThis.atob || ((s) => { 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;
+24
View File
@@ -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::<u32>() {
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::<u32>().unwrap_or(0);
dom.with_node_mut(NodeId::new(nid), |n| {
+137
View File
@@ -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<String> {
@@ -2657,6 +2691,109 @@ mod tests {
assert!(html.as_str().unwrap().contains("<p>"));
}
#[test]
fn template_inner_html_preserves_table_fragments() {
let mut rt = setup_runtime("<html><body></body></html>");
let result = rt
.evaluate(
r#"
const template = document.createElement('template');
template.innerHTML = '<tr><td>first</td><td>second</td></tr>';
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("<html><head></head><body></body></html>");
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("<html><body></body></html>");
// 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("<html><body></body></html>");
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("<html><head></head><body><div class=\"card\"></div></body></html>");
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("<ul><li>A</li><li>B</li></ul>");
+131 -20
View File
@@ -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 `<a>`/`<time>`/`<span>` becomes
// a block-level grid item and lays its text out as its own box instead
// of being flattened into loose words in the container's cell (which
// shattered grid lists like jvns.ca's `.article-list{display:grid}` of
// alternating `<time>`/`<a>` 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 `<td>`, whose inline text must stay inline.
let grid_parents: Vec<NodeId> = 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,
// `<pre class=flex><code>...</code></pre>` collapses the code item and
// navigation bars treat direct `<a>` 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<NodeId> = 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<NodeId> = 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()
}
}
+18
View File
@@ -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]
@@ -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"));
+8
View File
@@ -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},
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>inline replaced flow</title>
<style>
html, body { margin: 0; }
.case {
position: absolute;
left: 0;
width: 300px;
line-height: 0;
}
.atomic {
display: inline-block;
height: 20px;
vertical-align: top;
}
.lead {
width: 60px;
background: #087f5b;
}
img {
width: 40px;
height: 20px;
vertical-align: top;
background: #1971c2;
}
.tail {
width: 70px;
background: #e8590c;
}
#default-inline { top: 0; }
#authored-block { top: 60px; }
#authored-block img { display: block; }
#mixed-cell {
position: absolute;
left: 0;
top: 160px;
width: 300px;
border-spacing: 0;
}
#mixed-cell table {
width: 100%;
border-spacing: 0;
}
#mixed-cell td { padding: 0; }
#mixed-cell .zero {
width: 0;
height: 0;
background: transparent;
}
#mixed-cell .rule {
height: 4px;
background: #7048e8;
}
#mixed-cell center { height: 20px; }
#mixed-cell .centered {
width: 100px;
background: #2b8a3e;
}
</style>
</head>
<body>
<div id="default-inline" class="case"><span class="atomic lead"></span><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSIyMCI+PHJlY3Qgd2lkdGg9IjQwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjMTk3MWMyIi8+PC9zdmc+" alt=""><span class="atomic tail"></span></div>
<div id="authored-block" class="case"><span class="atomic lead"></span><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSIyMCI+PHJlY3Qgd2lkdGg9IjQwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjMTk3MWMyIi8+PC9zdmc+" alt=""><span class="atomic tail"></span></div>
<table id="mixed-cell">
<tr>
<td>
<img class="zero" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSIyMCI+PHJlY3Qgd2lkdGg9IjQwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjMTk3MWMyIi8+PC9zdmc+" alt="">
<table><tr><td class="rule"></td></tr></table>
<center><span class="atomic centered"></span></center>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,19 @@
<!doctype html>
<meta charset="utf-8">
<style>
#outer { width: 400px; }
#wrapper, #pre { height: 100%; }
#stack { display: flex; flex-direction: column; width: 100%; position: relative; }
#editor { flex: 1 1; position: relative; overflow: auto; }
#pre { display: flex; }
#code { display: block; line-height: 24px; }
</style>
<div id="outer">
<div id="wrapper">
<div id="stack">
<div id="editor">
<pre id="pre"><code id="code">content must give an indefinite percentage-height chain its height</code></pre>
</div>
</div>
</div>
</div>