Improve render lifecycle, geometry, and PDF parity
This commit is contained in:
1 parent
50b2ea14ff
commit
68f20cf525
12 files changed
+3279
-411
No files matched your search
Vendored
+394
-61
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user