render: support modern framework content and geometry
This commit is contained in:
1 parent
9acd74dbea
commit
228a5723ce
12 files changed
+843
-31
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Vendored
+200
-6
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user