Improve render lifecycle, geometry, and PDF parity

This commit is contained in:
SGavrl committed 2026-08-04 00:32:43 +00:00
1 parent 50b2ea14ff
commit 68f20cf525
12 files changed
+3279 -411

No files matched your search

+394 -61
View File
@@ -90,7 +90,14 @@ const _DOM_MUTATION_COMMANDS = new Set([
]);
const _dom = (cmd, a1, a2) => {
const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? ""));
if (_DOM_MUTATION_COMMANDS.has(cmd)) _domMutationEpoch++;
if (_DOM_MUTATION_COMMANDS.has(cmd)) {
_domMutationEpoch++;
// Resize observation is tied to rendering-invalidating DOM work. The
// hook is installed later in bootstrap, before page script can run.
if (typeof globalThis.__obscura_recompute_resizes === "function") {
globalThis.__obscura_recompute_resizes();
}
}
return result;
};
@@ -3240,6 +3247,7 @@ class Element extends Node {
if (changed && !this._scrollSuppress) this._fireScroll();
if (changed &&
typeof globalThis.__obscura_recompute_intersections === "function") {
// Scrolling changes target positions, not ResizeObserver box sizes.
globalThis.__obscura_recompute_intersections();
}
}
@@ -4634,6 +4642,13 @@ class HTMLImageElement extends Element {
_applyImageMetadata(metadata, request, dispatchEvent) {
if (request !== this._imageRequest) return;
const previousLifecycle = [
this._imageComplete,
this._imageDecoded,
this._imageCurrentSrc,
this._imageNaturalWidth,
this._imageNaturalHeight,
];
const selected = metadata && metadata.currentSrc
? String(metadata.currentSrc)
: "";
@@ -4666,6 +4681,18 @@ class HTMLImageElement extends Element {
try { this.dispatchEvent(new Event("error")); } catch (_error) {}
}
}
const lifecycleChanged =
previousLifecycle[0] !== this._imageComplete ||
previousLifecycle[1] !== this._imageDecoded ||
previousLifecycle[2] !== this._imageCurrentSrc ||
previousLifecycle[3] !== this._imageNaturalWidth ||
previousLifecycle[4] !== this._imageNaturalHeight;
if (lifecycleChanged) {
// Intrinsic dimensions can become layout input at request completion
// even though no DOM attribute changed. Stable cache-only getters must
// not manufacture rendering updates on every read.
_scheduleResizeRenderCheckpoint();
}
}
_resolveImageDecodes(request) {
@@ -5959,43 +5986,280 @@ if (!('isConnected' in Node.prototype)) {
});
}
// Resize observation is part of the rendering update, not a timer. Keep the
// last delivered size for each observed box and perform one coalesced geometry
// checkpoint after DOM/viewport work. This follows the browser lifecycle and,
// importantly, does not keep the event loop alive with speculative re-fires.
globalThis.__resizeObservers = [];
let _resizeRenderCheckpointPending = false;
let _resizeRenderCheckpointRunning = false;
let _resizeRenderCheckpointRerun = false;
function _registerResizeObserver(observer) {
if (!globalThis.__resizeObservers.includes(observer)) {
globalThis.__resizeObservers.push(observer);
}
}
function _unregisterResizeObserver(observer) {
const index = globalThis.__resizeObservers.indexOf(observer);
if (index >= 0) globalThis.__resizeObservers.splice(index, 1);
}
function _scheduleResizeRenderCheckpoint() {
if (!globalThis.__resizeObservers.length) return;
if (_resizeRenderCheckpointRunning) {
_resizeRenderCheckpointRerun = true;
return;
}
if (_resizeRenderCheckpointPending) return;
_resizeRenderCheckpointPending = true;
_scheduleAfter(0, () => {
_resizeRenderCheckpointPending = false;
_resizeRenderCheckpointRunning = true;
let depth = 0;
let skipped = false;
// Depth strictly increases after each broadcast, so this is naturally
// bounded by tree depth. Keep a hard ceiling for adversarial callbacks
// that manufacture an ever-deeper subtree during one delivery cycle.
for (let iteration = 0; iteration < 64; iteration++) {
_resizeRenderCheckpointRerun = false;
const measurements = new Map();
let shallowest = Infinity;
let active = false;
skipped = false;
const observers = [...globalThis.__resizeObservers];
// Gather every observer before invoking any callback. A callback from an
// earlier observer must not change the geometry gathered for a later one.
for (const observer of observers) {
const gathered = observer._gather(measurements, depth);
active = active || gathered.active;
skipped = skipped || gathered.skipped;
shallowest = Math.min(shallowest, gathered.shallowest);
}
if (!active) break;
for (const observer of observers) observer._broadcast();
depth = shallowest;
if (!_resizeRenderCheckpointRerun) break;
if (iteration === 63) skipped = true;
}
_resizeRenderCheckpointRunning = false;
_resizeRenderCheckpointRerun = false;
if (skipped) {
// Match the standardized loop-limit signal without queuing another
// internal task that could keep a pathological page permanently busy.
try {
globalThis.dispatchEvent(new ErrorEvent("error", {
message: "ResizeObserver loop completed with undelivered notifications."
}));
} catch (_error) {}
}
});
}
globalThis.__obscura_recompute_resizes = _scheduleResizeRenderCheckpoint;
function _roNumber(value) {
const number = Number.parseFloat(value);
return Number.isFinite(number) ? number : 0;
}
function _roPhysicalSize(inlineSize, blockSize, vertical) {
return vertical
? new ResizeObserverSize(_roConstructionKey, blockSize, inlineSize)
: new ResizeObserverSize(_roConstructionKey, inlineSize, blockSize);
}
function _roNodeDepth(target) {
let depth = 1;
let node = target;
while (node && (node = node.parentNode || node.host || null)) depth++;
return depth;
}
function _roMeasurement(target) {
let geometry = null;
const hasRenderer = typeof Deno.core.ops.op_layout_geometry === "function";
if (hasRenderer && target?._nid != null) {
try {
const raw = Deno.core.ops.op_layout_geometry(String(target._nid | 0));
geometry = raw ? JSON.parse(raw) : null;
} catch (_error) {}
}
// Preserve deterministic geometry in non-render builds. This path has no
// native layout cache, but lifecycle behavior (initial delivery and
// change-only rechecks) should remain useful to automation consumers.
if (!hasRenderer && target?.getBoundingClientRect) {
const rect = target.getBoundingClientRect();
geometry = {
x: rect.x, y: rect.y,
clientWidth: rect.width, clientHeight: rect.height,
};
}
// No renderer box (detached, display:none) has zero sizes. The initial zero
// is still delivered because an observation starts without a reported size.
if (!geometry) {
const zero = _roPhysicalSize(0, 0, false);
return {
contentRect: _ioRect(0, 0, 0, 0),
contentBoxSize: [zero],
borderBoxSize: [_roPhysicalSize(0, 0, false)],
devicePixelContentBoxSize: [_roPhysicalSize(0, 0, false)],
selected: { "content-box": [0, 0], "border-box": [0, 0], "device-pixel-content-box": [0, 0] },
};
}
const style = getComputedStyle(target);
const paddingTop = _roNumber(style.paddingTop);
const paddingRight = _roNumber(style.paddingRight);
const paddingBottom = _roNumber(style.paddingBottom);
const paddingLeft = _roNumber(style.paddingLeft);
const borderTop = _roNumber(style.borderTopWidth);
const borderRight = _roNumber(style.borderRightWidth);
const borderBottom = _roNumber(style.borderBottomWidth);
const borderLeft = _roNumber(style.borderLeftWidth);
const clientWidth = Math.max(0, Number(geometry.clientWidth) || 0);
const clientHeight = Math.max(0, Number(geometry.clientHeight) || 0);
const contentWidth = Math.max(0, clientWidth - paddingLeft - paddingRight);
const contentHeight = Math.max(0, clientHeight - paddingTop - paddingBottom);
const borderWidth = Math.max(0, clientWidth + borderLeft + borderRight);
const borderHeight = Math.max(0, clientHeight + borderTop + borderBottom);
const vertical = /^(?:vertical|sideways)/.test(style.writingMode || "");
// Per Resize Observer, ordinary non-replaced inline elements have an empty
// observed box even though getBoundingClientRect() encloses their glyphs.
const replaced = /^(?:IMG|VIDEO|AUDIO|IFRAME|EMBED|OBJECT|INPUT|TEXTAREA|SELECT|CANVAS|SVG)$/.test(
target.tagName || ""
);
const emptyInline = style.display === "inline" && !replaced;
const observedContentWidth = emptyInline ? 0 : contentWidth;
const observedContentHeight = emptyInline ? 0 : contentHeight;
const observedBorderWidth = emptyInline ? 0 : borderWidth;
const observedBorderHeight = emptyInline ? 0 : borderHeight;
const contentSize = _roPhysicalSize(observedContentWidth, observedContentHeight, vertical);
const borderSize = _roPhysicalSize(observedBorderWidth, observedBorderHeight, vertical);
// Device-pixel content sizes snap the content edges, rather than merely
// rounding a CSS size multiplied by DPR. Preserve that distinction for
// fractional positions and dimensions.
const dpr = Math.max(0, Number(globalThis.devicePixelRatio) || 1);
const contentLeft = (Number(geometry.x) + borderLeft + paddingLeft) * dpr;
const contentTop = (Number(geometry.y) + borderTop + paddingTop) * dpr;
const deviceWidth = emptyInline ? 0 : Math.max(0,
Math.round(contentLeft + contentWidth * dpr) - Math.round(contentLeft));
const deviceHeight = emptyInline ? 0 : Math.max(0,
Math.round(contentTop + contentHeight * dpr) - Math.round(contentTop));
const deviceSize = _roPhysicalSize(deviceWidth, deviceHeight, vertical);
return {
contentRect: emptyInline
? _ioRect(0, 0, 0, 0)
: _ioRect(paddingLeft, paddingTop, contentWidth, contentHeight),
contentBoxSize: [contentSize],
borderBoxSize: [borderSize],
devicePixelContentBoxSize: [deviceSize],
selected: {
"content-box": [contentSize.inlineSize, contentSize.blockSize],
"border-box": [borderSize.inlineSize, borderSize.blockSize],
"device-pixel-content-box": [deviceSize.inlineSize, deviceSize.blockSize],
},
};
}
const _roConstructionKey = {};
const _roSizeValues = new WeakMap();
globalThis.ResizeObserverSize = class ResizeObserverSize {
constructor(key, inlineSize, blockSize) {
if (key !== _roConstructionKey) throw new TypeError("Illegal constructor");
_roSizeValues.set(this, { inlineSize, blockSize });
}
get inlineSize() { return _roSizeValues.get(this)?.inlineSize; }
get blockSize() { return _roSizeValues.get(this)?.blockSize; }
};
const _roEntryValues = new WeakMap();
globalThis.ResizeObserverEntry = class ResizeObserverEntry {
constructor(key, target, measurement) {
if (key !== _roConstructionKey) throw new TypeError("Illegal constructor");
_roEntryValues.set(this, { target, measurement });
}
get target() { return _roEntryValues.get(this)?.target; }
get contentRect() { return _roEntryValues.get(this)?.measurement.contentRect; }
get borderBoxSize() { return _roEntryValues.get(this)?.measurement.borderBoxSize; }
get contentBoxSize() { return _roEntryValues.get(this)?.measurement.contentBoxSize; }
get devicePixelContentBoxSize() {
return _roEntryValues.get(this)?.measurement.devicePixelContentBoxSize;
}
};
globalThis.ResizeObserver = class ResizeObserver {
constructor(callback) {
if (typeof callback !== "function") {
throw new TypeError("ResizeObserver callback must be a function");
}
this._callback = callback;
this._targets = new Set();
this._connected = true;
this._fireCount = 0;
this._targets = new Map();
this._active = [];
this._skipped = false;
}
_fireFor(targets) {
if (!this._connected || !targets.length) return;
const records = targets.map(target => {
const r = target.getBoundingClientRect ? target.getBoundingClientRect() : { x: 0, y: 0, width: 100, height: 20 };
return {
target,
contentRect: { x: r.x || 0, y: r.y || 0, width: r.width || 100, height: r.height || 20, top: r.top || 0, left: r.left || 0, bottom: r.bottom || 20, right: r.right || 100 },
borderBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
contentBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
devicePixelContentBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
};
_gather(measurements, depth) {
this._active = [];
this._skipped = false;
let shallowest = Infinity;
for (const [target, observation] of this._targets) {
let measurement = measurements.get(target);
if (!measurement) {
measurement = _roMeasurement(target);
measurements.set(target, measurement);
}
const size = measurement.selected[observation.box];
const last = observation.last;
if (last && last[0] === size[0] && last[1] === size[1]) continue;
const targetDepth = _roNodeDepth(target);
// A callback may disconnect and begin observing a different target.
// Browsers deliver that initial observation on the next rendering
// opportunity. We fold that opportunity into this bounded cycle so it
// does not require a persistent frame timer; already-reported targets
// still obey the loop-depth guard.
if (targetDepth <= depth && last) {
this._skipped = true;
continue;
}
shallowest = Math.min(shallowest, targetDepth);
this._active.push({ target, observation, measurement, size });
}
return {
active: this._active.length > 0,
skipped: this._skipped,
shallowest,
};
}
_broadcast() {
if (!this._active.length) return;
const entries = this._active.map(({ target, observation, measurement, size }) => {
// Update before invoking callbacks. Callback-driven mutations are
// compared against this delivery in the same bounded delivery cycle.
observation.last = size.slice();
return new ResizeObserverEntry(_roConstructionKey, target, measurement);
});
try { this._callback(records, this); } catch (e) { /* RO callbacks must not propagate */ }
this._active = [];
try { this._callback(entries, this); } catch (_error) {}
}
observe(el) {
if (!el || !this._connected) return;
if (this._targets.has(el)) return;
this._targets.add(el);
Promise.resolve().then(() => this._fireFor([el]));
[200, 800].forEach(delay => {
setTimeout(() => {
if (this._connected && this._targets.has(el) && this._fireCount < 16) {
this._fireCount++;
this._fireFor([el]);
}
}, delay);
});
observe(target, options = {}) {
if (!(target instanceof Element)) {
throw new TypeError("ResizeObserver.observe requires an Element");
}
const box = options && options.box != null ? String(options.box) : "content-box";
if (box !== "content-box" && box !== "border-box" &&
box !== "device-pixel-content-box") {
throw new TypeError(`Invalid ResizeObserver box option: ${box}`);
}
const current = this._targets.get(target);
if (current && current.box === box) return;
this._targets.set(target, { box, last: null });
_registerResizeObserver(this);
_scheduleResizeRenderCheckpoint();
}
unobserve(target) {
this._targets.delete(target);
if (!this._targets.size) _unregisterResizeObserver(this);
}
disconnect() {
this._targets.clear();
this._active = [];
this._skipped = false;
_unregisterResizeObserver(this);
}
unobserve(el) { this._targets.delete(el); }
disconnect() { this._connected = false; this._targets.clear(); }
};
if (typeof TextEncoder === 'undefined') {
@@ -6786,12 +7050,19 @@ function _ioMargins(value) {
if (parsed.length === 3) return [parsed[0], parsed[1], parsed[2], parsed[1]];
return parsed;
}
function _ioUnsupportedRoot() {
const error = new Error(
"Obscura IntersectionObserver element roots require element scroll-container layout"
);
error.name = "NotSupportedError";
return error;
function _ioClipsOverflow(value) {
return /^(?:auto|clip|hidden|overlay|scroll)$/.test(String(value || ""));
}
function _ioElementPaddingBox(element, style) {
const geometry = element._renderBoxGeometry();
const rect = geometry
? element._rectFromRenderGeometry(geometry)
: element.getBoundingClientRect();
const borderLeft = _roNumber(style.borderLeftWidth);
const borderTop = _roNumber(style.borderTopWidth);
const width = geometry ? geometry.clientWidth : element.clientWidth;
const height = geometry ? geometry.clientHeight : element.clientHeight;
return _ioRect(rect.left + borderLeft, rect.top + borderTop, width, height);
}
globalThis.IntersectionObserver = class IntersectionObserver {
constructor(callback, options) {
@@ -6800,7 +7071,11 @@ globalThis.IntersectionObserver = class IntersectionObserver {
}
this._callback = callback;
this._options = options || {};
if (this._options.root != null) throw _ioUnsupportedRoot();
this._root = this._options.root == null ? null : this._options.root;
if (this._root !== null && !(this._root instanceof Element) &&
this._root?.nodeType !== 9) {
throw new TypeError("IntersectionObserver root must be an Element or Document");
}
this._margins = _ioMargins(this._options.rootMargin || "0px");
if (!this._margins) throw new SyntaxError("Invalid IntersectionObserver rootMargin");
const raw = this._options.threshold == null
@@ -6819,8 +7094,29 @@ globalThis.IntersectionObserver = class IntersectionObserver {
globalThis.__intersectionObservers.push(this);
}
_rootBounds() {
const width = globalThis.innerWidth || 1280;
const height = globalThis.innerHeight || 720;
let x = 0, y = 0;
let width = globalThis.innerWidth || 1280;
let height = globalThis.innerHeight || 720;
if (this._root instanceof Element) {
const style = getComputedStyle(this._root);
const clips = _ioClipsOverflow(style.overflowX) ||
_ioClipsOverflow(style.overflowY);
if (clips) {
const paddingBox = _ioElementPaddingBox(this._root, style);
x = paddingBox.left;
y = paddingBox.top;
// The intersection root for a content-clipping element is its padding
// box (the CSSOM client box), independent of its current scroll offset.
width = paddingBox.width;
height = paddingBox.height;
} else {
const rect = this._root.getBoundingClientRect();
x = rect.left;
y = rect.top;
width = rect.width;
height = rect.height;
}
}
const resolve = (margin, basis) =>
margin.unit === "%" ? margin.value * basis / 100 : margin.value;
// IntersectionObserver resolves every rootMargin percentage against the
@@ -6829,27 +7125,55 @@ globalThis.IntersectionObserver = class IntersectionObserver {
const right = resolve(this._margins[1], width);
const bottom = resolve(this._margins[2], width);
const left = resolve(this._margins[3], width);
return _ioRect(-left, -top, width + left + right, height + top + bottom);
return _ioRect(x - left, y - top, width + left + right, height + top + bottom);
}
_entry(target) {
_entry(target, root) {
const rect = target.getBoundingClientRect();
const root = this._rootBounds();
const left = Math.max(rect.left, root.left);
const top = Math.max(rect.top, root.top);
const right = Math.min(rect.right, root.right);
const bottom = Math.min(rect.bottom, root.bottom);
const edgesTouch = right >= left && bottom >= top;
let inRootTree = !(this._root instanceof Element) || this._root.contains(target);
let left = Math.max(rect.left, root.left);
let top = Math.max(rect.top, root.top);
let right = Math.min(rect.right, root.right);
let bottom = Math.min(rect.bottom, root.bottom);
// Mapping a target to its intersection root clips it at every intervening
// overflow container. Intersecting only with the final root incorrectly
// exposes offscreen children of nested carousels, virtual lists, and lazy
// loading viewports. Use each ancestor's padding box, independently by
// axis, matching Chromium's rectangular overflow clip chain.
let ancestor = target.parentNode || target.host || null;
while (inRootTree && ancestor && ancestor !== this._root && ancestor.nodeType !== 9) {
if (ancestor instanceof Element) {
const style = getComputedStyle(ancestor);
const clipX = _ioClipsOverflow(style.overflowX);
const clipY = _ioClipsOverflow(style.overflowY);
if (clipX || clipY) {
const clip = _ioElementPaddingBox(ancestor, style);
if (clipX) {
left = Math.max(left, clip.left);
right = Math.min(right, clip.right);
}
if (clipY) {
top = Math.max(top, clip.top);
bottom = Math.min(bottom, clip.bottom);
}
}
}
ancestor = ancestor.parentNode || ancestor.host || null;
}
if (this._root instanceof Element && ancestor !== this._root) inRootTree = false;
const edgesTouch = inRootTree && right >= left && bottom >= top;
const width = Math.max(0, right - left);
const height = Math.max(0, bottom - top);
const targetArea = Math.max(0, rect.width) * Math.max(0, rect.height);
const area = width * height;
const isIntersecting = edgesTouch;
const area = isIntersecting ? width * height : 0;
return {
target,
isIntersecting,
intersectionRatio: targetArea > 0 ? area / targetArea : (isIntersecting ? 1 : 0),
boundingClientRect: _ioRect(rect.x, rect.y, rect.width, rect.height),
intersectionRect: _ioRect(left, top, width, height),
intersectionRect: isIntersecting ? _ioRect(left, top, width, height) : _ioRect(0, 0, 0, 0),
rootBounds: root,
time: performance.now(),
};
@@ -6859,8 +7183,8 @@ globalThis.IntersectionObserver = class IntersectionObserver {
while (index < this._thresholds.length && this._thresholds[index] <= ratio) index++;
return index;
}
_queueChanged(target, forceInitial) {
const entry = this._entry(target);
_queueChanged(target, forceInitial, root) {
const entry = this._entry(target, root);
const previous = this._previous.get(target);
const changed = forceInitial || !previous ||
previous.isIntersecting !== entry.isIntersecting ||
@@ -6874,8 +7198,9 @@ globalThis.IntersectionObserver = class IntersectionObserver {
}
_check(targets, forceInitial) {
if (!this._connected) return;
const root = this._rootBounds();
for (const target of targets) {
if (this._targets.has(target)) this._queueChanged(target, !!forceInitial);
if (this._targets.has(target)) this._queueChanged(target, !!forceInitial, root);
}
if (!this._records.length || this._deliveryPending) return;
this._deliveryPending = true;
@@ -6915,23 +7240,30 @@ globalThis.IntersectionObserver = class IntersectionObserver {
if (index >= 0) globalThis.__intersectionObservers.splice(index, 1);
}
takeRecords() { return this._records.splice(0); }
get root() { return null; }
get root() { return this._root; }
get rootMargin() {
return this._margins.map((margin) => `${margin.value}${margin.unit}`).join(" ");
}
get thresholds() { return this._thresholds.slice(); }
};
(function() {
const recompute = () => {
const renderingUpdate = () => {
_scheduleIntersectionRenderCheckpoint();
_scheduleResizeRenderCheckpoint();
};
globalThis.__obscura_recompute_intersections = recompute;
globalThis.addEventListener("resize", recompute);
// Scrolling calls the IO-only hook. Actual viewport resizing remains a full
// rendering update and schedules both observer families.
globalThis.__obscura_recompute_intersections = _scheduleIntersectionRenderCheckpoint;
globalThis.addEventListener("resize", renderingUpdate);
const wireUp = () => {
if (!globalThis.document?.body) return;
const observer = new MutationObserver(recompute);
if (!globalThis.document) return;
// DOM writes synchronously mark ResizeObserver dirty through `_dom`; this
// MutationObserver is only needed for intersection geometry. Scheduling RO
// again here would escape its depth-bounded delivery cycle and allow a
// self-resizing callback to create an infinite chain of zero-delay tasks.
const observer = new MutationObserver(_scheduleIntersectionRenderCheckpoint);
try {
observer.observe(globalThis.document.body, {
observer.observe(globalThis.document, {
childList: true,
subtree: true,
attributes: true,
@@ -6939,7 +7271,7 @@ globalThis.IntersectionObserver = class IntersectionObserver {
});
} catch {}
};
if (globalThis.document?.body) wireUp();
if (globalThis.document) wireUp();
else Promise.resolve().then(wireUp);
})();
globalThis.IntersectionObserverEntry = class IntersectionObserverEntry {};
@@ -11491,6 +11823,7 @@ if (typeof FontFace === 'undefined') {
unicodeRange: face.unicodeRange
});
Deno.core.ops.op_set_dynamic_fonts(JSON.stringify(registrations.filter(face => !face.skip)));
_scheduleResizeRenderCheckpoint();
}
_faceChanged(face) {
this._syncNative();
+23
View File
@@ -3271,6 +3271,20 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
}
let base_url = document_base_url(&gs);
let viewport = gs.viewport;
let previous_dimensions = (!cached_only)
.then(|| {
gs.dom.as_ref().and_then(|dom| {
gs.render_resources
.cached_image_element_metadata(
dom,
node_id,
viewport,
base_url.as_deref(),
)
.and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten())
})
})
.flatten();
let ObscuraState {
dom,
render_resources,
@@ -3317,6 +3331,15 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
})
.to_string();
}
// The page transport invalidates prepared layout when it seeds successful
// bytes. Cover the synchronous compatibility-loader path too, but only
// when this call actually discovers a new decodable intrinsic size. A
// cache hit or retained decode failure cannot change used geometry.
if !cached_only && dimensions.is_some() && dimensions != previous_dimensions {
gs.prepared_render = None;
gs.pending_style_mutations.clear();
gs.resolved_scroll = None;
}
match dimensions {
Some((width, height)) => serde_json::json!({
"state": "loaded",
+612 -5
View File
@@ -412,6 +412,9 @@ impl ObscuraJsRuntime {
}}\
if(typeof globalThis.__obscura_recompute_intersections==='function'){{\
globalThis.__obscura_recompute_intersections();\
}}\
if(typeof globalThis.__obscura_recompute_resizes==='function'){{\
globalThis.__obscura_recompute_resizes();\
}}",
),
);
@@ -6444,6 +6447,310 @@ mod tests {
assert_eq!(values[2], values[3]);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn resize_observer_reports_real_boxes_only_when_selected_size_changes() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="target" style="box-sizing:border-box;width:120px;height:80px;
padding:5px 7px;border:2px solid black"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(300.0, 200.0);
rt.run_page_init();
rt.execute_script(
"resize-observer-boxes",
r#"
globalThis.__resizeRecords = [];
globalThis.__resizeObserver = new ResizeObserver(entries => {
__resizeRecords.push(...entries.map(entry => ({
interfaces: [
entry instanceof ResizeObserverEntry,
entry.contentBoxSize[0] instanceof ResizeObserverSize,
entry.borderBoxSize[0] instanceof ResizeObserverSize,
entry.devicePixelContentBoxSize[0] instanceof ResizeObserverSize,
],
contentRect: [
entry.contentRect.x, entry.contentRect.y,
entry.contentRect.width, entry.contentRect.height,
],
content: [
entry.contentBoxSize[0].inlineSize,
entry.contentBoxSize[0].blockSize,
],
border: [
entry.borderBoxSize[0].inlineSize,
entry.borderBoxSize[0].blockSize,
],
device: [
entry.devicePixelContentBoxSize[0].inlineSize,
entry.devicePixelContentBoxSize[0].blockSize,
],
})));
});
__resizeObserver.observe(document.getElementById("target"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("__resizeRecords").unwrap(),
serde_json::json!([{
"interfaces": [true, true, true, true],
"contentRect": [7, 5, 102, 66],
"content": [102, 66],
"border": [120, 80],
"device": [102, 66],
}])
);
// A style mutation still causes a rendering checkpoint, but unchanged
// observed geometry must not produce a speculative notification.
rt.evaluate(r#"document.getElementById("target").style.color = "red""#)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("__resizeRecords.length").unwrap().as_f64(),
Some(1.0)
);
rt.evaluate(r#"document.getElementById("target").style.width = "140px""#)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate(
"__resizeRecords.map(record => [record.content[0], record.border[0]])"
)
.unwrap(),
serde_json::json!([[102, 120], [122, 140]])
);
assert_eq!(
rt.evaluate("__obscura_nextPendingTimeoutDelay()")
.unwrap()
.as_f64(),
Some(-1.0)
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn resize_observer_selected_box_and_viewport_lifecycle_match_chromium() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="target" style="box-sizing:border-box;width:50vw;height:40px;
padding:4px;border:2px solid"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
rt.execute_script(
"resize-observer-selected-box",
r#"
globalThis.__contentWidths = [];
globalThis.__borderWidths = [];
const target = document.getElementById("target");
globalThis.__contentObserver = new ResizeObserver(entries => {
__contentWidths.push(entries[0].contentBoxSize[0].inlineSize);
});
globalThis.__borderObserver = new ResizeObserver(entries => {
__borderWidths.push(entries[0].borderBoxSize[0].inlineSize);
});
__contentObserver.observe(target, { box: "content-box" });
__borderObserver.observe(target, { box: "border-box" });
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
serde_json::json!([[88], [100]])
);
// A viewport update is a rendering update even without a DOM mutation.
rt.set_viewport(300.0, 100.0);
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
serde_json::json!([[88, 138], [100, 150]])
);
// With border-box sizing a thicker border shrinks the content box but
// leaves the selected border box unchanged.
rt.evaluate(r#"document.getElementById("target").style.borderWidth = "4px""#)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
serde_json::json!([[88, 138, 134], [100, 150]])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn scrolling_does_not_remeasure_resize_observer_targets() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0;height:1000px">
<div id="probe" style="width:40px;height:20px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
rt.execute_script(
"observe-before-scroll",
r#"
globalThis.__scrollResizeRecords = 0;
globalThis.__scrollResizeObserver = new ResizeObserver(entries => {
__scrollResizeRecords += entries.length;
});
__scrollResizeObserver.observe(document.getElementById("probe"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("__scrollResizeRecords").unwrap().as_f64(),
Some(1.0)
);
rt.execute_script(
"count-scroll-geometry-reads",
r#"
globalThis.__scrollGeometryReads = 0;
globalThis.__nativeLayoutGeometry = Deno.core.ops.op_layout_geometry;
Deno.core.ops.op_layout_geometry = (...args) => {
__scrollGeometryReads++;
return __nativeLayoutGeometry(...args);
};
window.scrollTo(0, 50);
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
let result = rt
.evaluate("[scrollY, __scrollGeometryReads, __scrollResizeRecords]")
.unwrap();
rt.execute_script(
"restore-layout-geometry-op",
"Deno.core.ops.op_layout_geometry = __nativeLayoutGeometry;",
)
.unwrap();
assert_eq!(result, serde_json::json!([50, 0, 1]));
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn resize_observer_disconnect_is_reusable_and_inline_boxes_are_empty() {
let dom = parse_html(
r#"<html><body><div id="first" style="width:40px;height:20px"></div>
<span id="inline" style="padding:8px;border:2px solid">text</span>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
const deliveries = [];
const observer = new ResizeObserver(entries => {
deliveries.push(entries.map(entry => [
entry.target.id,
entry.contentRect.width,
entry.borderBoxSize[0].inlineSize,
]));
if (deliveries.length === 1) {
observer.disconnect();
observer.observe(document.getElementById("inline"));
} else {
observer.disconnect();
resolve([deliveries, __resizeObservers.length]);
}
});
observer.observe(document.getElementById("first"));
setTimeout(() => resolve(["timed out"]), 100);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(
result.value.unwrap(),
serde_json::json!([[[["first", 40, 40]], [["inline", 0, 0]]], 0])
);
assert_eq!(
rt.evaluate(
r#"[
(() => { try { new ResizeObserver(null); } catch (e) { return e.name; } })(),
(() => { try { new ResizeObserver(() => {}).observe(document); } catch (e) { return e.name; } })(),
(() => { try { new ResizeObserver(() => {}).observe(document.body, {box:"margin-box"}); } catch (e) { return e.name; } })(),
]"#,
)
.unwrap(),
serde_json::json!(["TypeError", "TypeError", "TypeError"])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn resize_observer_self_resize_is_depth_bounded_without_timer_spin() {
let dom = parse_html(
r#"<html><body><div id="target" style="width:40px;height:20px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
rt.execute_script(
"resize-observer-loop-limit",
r#"
globalThis.__resizeCallbacks = 0;
globalThis.__resizeLoopErrors = 0;
addEventListener("error", event => {
if (event.message === "ResizeObserver loop completed with undelivered notifications.") {
__resizeLoopErrors++;
}
});
const target = document.getElementById("target");
globalThis.__loopingResizeObserver = new ResizeObserver(() => {
__resizeCallbacks++;
target.style.width = (40 + __resizeCallbacks) + "px";
});
__loopingResizeObserver.observe(target);
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate(
"[__resizeCallbacks, __resizeLoopErrors, __obscura_nextPendingTimeoutDelay()]"
)
.unwrap(),
serde_json::json!([1, 1, -1])
);
// A later external rendering change starts a fresh bounded cycle; the
// suppressed same-depth observation did not poison future delivery.
rt.evaluate(r#"document.getElementById("target").style.width = "60px""#)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("[__resizeCallbacks, __resizeLoopErrors]").unwrap(),
serde_json::json!([2, 2])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_tracks_viewport_threshold_crossings() {
@@ -6492,6 +6799,134 @@ mod tests {
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_element_root_uses_live_padding_box_and_scroll() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="root" style="position:absolute;left:10px;top:20px;width:100px;
height:80px;padding:10px;border:5px solid;overflow:auto">
<div style="height:100px"></div>
<div id="target" style="height:20px"></div>
</div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(300.0, 200.0);
rt.run_page_init();
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
const records = [];
const root = document.getElementById("root");
const observer = new IntersectionObserver(entries => {
records.push(...entries.map(entry => ({
intersecting: entry.isIntersecting,
ratio: entry.intersectionRatio,
root: [
entry.rootBounds.x, entry.rootBounds.y,
entry.rootBounds.width, entry.rootBounds.height,
],
intersection: [
entry.intersectionRect.x, entry.intersectionRect.y,
entry.intersectionRect.width, entry.intersectionRect.height,
],
})));
}, { root, threshold: [0, 1] });
observer.observe(document.getElementById("target"));
setTimeout(() => { root.scrollTop = 999; }, 5);
setTimeout(() => resolve(records), 30);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(
result.value.unwrap(),
serde_json::json!([
{
"intersecting": false,
"ratio": 0,
"root": [15, 25, 120, 100],
"intersection": [0, 0, 0, 0],
},
{
"intersecting": true,
"ratio": 1,
"root": [15, 25, 120, 100],
"intersection": [25, 95, 100, 20],
},
])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_clips_through_intermediate_overflow_ancestors() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="root" style="position:absolute;left:10px;top:20px;
width:300px;height:300px;overflow:visible">
<div id="clip" style="width:100px;height:100px;overflow:hidden">
<div style="height:150px"></div>
<div id="target" style="height:20px"></div>
</div>
</div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(400.0, 400.0);
rt.run_page_init();
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
const records = [];
const clip = document.getElementById("clip");
const observer = new IntersectionObserver(entries => {
records.push(...entries.map(entry => [
entry.isIntersecting,
entry.intersectionRatio,
[
entry.intersectionRect.x,
entry.intersectionRect.y,
entry.intersectionRect.width,
entry.intersectionRect.height,
],
]));
}, {
root: document.getElementById("root"),
threshold: [0, 1],
});
observer.observe(document.getElementById("target"));
setTimeout(() => { clip.scrollTop = 999; }, 5);
setTimeout(() => resolve(records), 30);
})
"#,
true,
true,
)
.await
.unwrap();
// Chromium reports the initial target as non-intersecting: although it
// lies inside the explicit root, the intermediate overflow container
// clips it. Programmatic scrolling then reveals the complete box.
assert_eq!(
result.value.unwrap(),
serde_json::json!([
[false, 0, [0, 0, 0, 0]],
[true, 1, [10, 100, 100, 20]],
])
);
}
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_initial_geometry_waits_for_one_render_checkpoint() {
let mut rt = setup_runtime(
@@ -6571,16 +7006,17 @@ mod tests {
);
marginObserver.observe(document.getElementById("margin-target"));
zeroObserver.observe(document.getElementById("zero"));
let unsupported = "";
let elementRoot = false;
try {
new IntersectionObserver(() => {}, {
const rooted = new IntersectionObserver(() => {}, {
root: document.getElementById("root")
});
elementRoot = rooted.root === document.getElementById("root");
} catch (error) {
unsupported = error.name;
elementRoot = error.name;
}
setTimeout(() => resolve([
marginRecords, zeroRecords, unsupported,
marginRecords, zeroRecords, elementRoot,
marginObserver.rootMargin, marginObserver.thresholds,
]), 200);
})
@@ -6595,7 +7031,7 @@ mod tests {
serde_json::json!([
[[true, 1, 120]],
[[true, 1]],
"NotSupportedError",
true,
"0px 0px 20px 0px",
[0, 1],
])
@@ -7776,6 +8212,177 @@ mod tests {
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn parser_image_fallback_invalidates_only_new_intrinsic_geometry() {
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let loader_calls = calls.clone();
let png = two_by_three_png();
let mut rt = parser_image_runtime(
r#"<img id="late" src="late.png">"#,
move |_url: &str| {
loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(png.clone())
},
);
{
let mut state = rt.state.borrow_mut();
let previous = state.render_resources.set_sync_loading_enabled(false);
assert!(ensure_prepared_render(&mut state).is_some());
state
.render_resources
.set_sync_loading_enabled(previous);
assert!(state.prepared_render.is_some());
}
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
rt.execute_script(
"load-image-after-layout",
r#"
globalThis.__lateEvents = [];
const late = document.getElementById("late");
late.addEventListener("load", () => __lateEvents.push("load"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate(
"[late.complete, late.naturalWidth, late.naturalHeight, __lateEvents]"
)
.unwrap(),
serde_json::json!([true, 2, 3, ["load"]])
);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(rt.state.borrow().prepared_render.is_none());
// Once the successful dimensions are retained, another loading-form
// metadata probe is only a cache hit and must preserve fresh layout.
{
let mut state = rt.state.borrow_mut();
assert!(ensure_prepared_render(&mut state).is_some());
assert!(state.prepared_render.is_some());
}
rt.execute_script(
"reload-retained-image",
r#"late.src = "late.png";"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(rt.state.borrow().prepared_render.is_some());
let missing_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let missing_loader_calls = missing_calls.clone();
let mut missing = parser_image_runtime(
r#"<img id="missing" src="missing.png">"#,
move |_url: &str| {
missing_loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
None
},
);
{
let mut state = missing.state.borrow_mut();
let previous = state.render_resources.set_sync_loading_enabled(false);
assert!(ensure_prepared_render(&mut state).is_some());
state
.render_resources
.set_sync_loading_enabled(previous);
assert!(state.prepared_render.is_some());
}
missing
.execute_script(
"fail-image-after-layout",
r#"
globalThis.__missingEvents = [];
const missing = document.getElementById("missing");
missing.addEventListener("error", () => __missingEvents.push("error"));
"#,
)
.unwrap();
missing.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
missing
.evaluate(
"[missing.complete, missing.naturalWidth, missing.naturalHeight, __missingEvents]"
)
.unwrap(),
serde_json::json!([true, 0, 0, ["error"]])
);
assert_eq!(
missing_calls.load(std::sync::atomic::Ordering::SeqCst),
1
);
assert!(missing.state.borrow().prepared_render.is_some());
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn stable_cached_image_getters_do_not_queue_resize_geometry_work() {
let png = two_by_three_png();
let mut rt = parser_image_runtime(
r#"<img id="cached" src="cached.png">
<div id="probe" style="width:40px;height:20px"></div>"#,
move |_url: &str| Some(png.clone()),
);
rt.execute_script(
"settle-cached-image",
r#"
const cached = document.getElementById("cached");
void cached.complete;
"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate("[cached.complete, cached.naturalWidth, cached.naturalHeight]")
.unwrap(),
serde_json::json!([true, 2, 3])
);
rt.execute_script(
"observe-unrelated-geometry",
r#"
globalThis.__stableGetterResizeRecords = 0;
globalThis.__stableGetterObserver = new ResizeObserver(entries => {
__stableGetterResizeRecords += entries.length;
});
__stableGetterObserver.observe(document.getElementById("probe"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate(
"[__stableGetterResizeRecords, __obscura_nextPendingTimeoutDelay()]"
)
.unwrap(),
serde_json::json!([1, -1])
);
rt.execute_script(
"read-stable-image-cache",
r#"
for (let i = 0; i < 50; i++) {
void cached.complete;
void cached.currentSrc;
void cached.naturalWidth;
void cached.naturalHeight;
}
"#,
)
.unwrap();
// Cached lifecycle reads do not change intrinsic dimensions, so they
// must not enqueue a rendering checkpoint (and its geometry walk).
assert_eq!(
rt.evaluate(
"[__stableGetterResizeRecords, __obscura_nextPendingTimeoutDelay()]"
)
.unwrap(),
serde_json::json!([1, -1])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn parser_image_source_replacement_cancels_queued_completion() {