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
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>");
|
||||
|
||||
Reference in new issue
Block a user