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

+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>");