Track responsive image candidates in lifecycle
This commit is contained in:
1 parent
6f71c302c5
commit
bae1259afe
4 files changed
+365
-45
No files matched your search
Vendored
+90
-18
@@ -1925,6 +1925,16 @@ class Element extends Node {
|
|||||||
if (n === "style") this._style._replaceFromAttribute(value);
|
if (n === "style") this._style._replaceFromAttribute(value);
|
||||||
if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev);
|
if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev);
|
||||||
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('attributes', this._nid, [], [], n);
|
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('attributes', this._nid, [], [], n);
|
||||||
|
if (this.localName === "source"
|
||||||
|
&& (n === "srcset" || n === "sizes" || n === "media" || n === "type")) {
|
||||||
|
const picture = this.parentElement;
|
||||||
|
const image = picture && picture.localName === "picture"
|
||||||
|
? picture.querySelector("img")
|
||||||
|
: null;
|
||||||
|
if (image && typeof image._imageSourceChanged === "function") {
|
||||||
|
image._imageSourceChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setAttributeNS(ns, n, v) {
|
setAttributeNS(ns, n, v) {
|
||||||
ns = ns == null || ns === '' ? '' : String(ns);
|
ns = ns == null || ns === '' ? '' : String(ns);
|
||||||
@@ -1934,7 +1944,23 @@ class Element extends Node {
|
|||||||
_dom("set_attribute_ns", this._nid, ns + "\0" + n + "\0" + value);
|
_dom("set_attribute_ns", this._nid, ns + "\0" + n + "\0" + value);
|
||||||
if (ns === "" && n === "style") this._style._replaceFromAttribute(value);
|
if (ns === "" && n === "style") this._style._replaceFromAttribute(value);
|
||||||
}
|
}
|
||||||
removeAttribute(n) { n = _htmlAttrName(this, n); const popoverPrev = (n === "popover") ? this.popover : undefined; _dom("remove_attribute", this._nid, n); if (n === "style") this._style._replaceFromAttribute(""); if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev); }
|
removeAttribute(n) {
|
||||||
|
n = _htmlAttrName(this, n);
|
||||||
|
const popoverPrev = (n === "popover") ? this.popover : undefined;
|
||||||
|
_dom("remove_attribute", this._nid, n);
|
||||||
|
if (n === "style") this._style._replaceFromAttribute("");
|
||||||
|
if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev);
|
||||||
|
if (this.localName === "source"
|
||||||
|
&& (n === "srcset" || n === "sizes" || n === "media" || n === "type")) {
|
||||||
|
const picture = this.parentElement;
|
||||||
|
const image = picture && picture.localName === "picture"
|
||||||
|
? picture.querySelector("img")
|
||||||
|
: null;
|
||||||
|
if (image && typeof image._imageSourceChanged === "function") {
|
||||||
|
image._imageSourceChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
removeAttributeNS(ns, n) {
|
removeAttributeNS(ns, n) {
|
||||||
ns = String(ns == null ? "" : ns);
|
ns = String(ns == null ? "" : ns);
|
||||||
n = String(n);
|
n = String(n);
|
||||||
@@ -3885,20 +3911,26 @@ function _imageEncodingError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HTMLImageElement is backed by the same retained resource cache used by
|
// HTMLImageElement is backed by the same retained resource cache used by
|
||||||
// layout/paint. The render-only native op fetches and sniffs the ordinary
|
// layout/paint. The render-only native op owns responsive candidate selection,
|
||||||
// `src`; bootstrap owns only the observable request state and event timing.
|
// fetching, and metadata sniffing; bootstrap owns only the observable request
|
||||||
// Responsive candidate selection remains explicitly deferred to the renderer.
|
// state and event timing.
|
||||||
class HTMLImageElement extends Element {
|
class HTMLImageElement extends Element {
|
||||||
constructor(nid) {
|
constructor(nid) {
|
||||||
super(nid);
|
super(nid);
|
||||||
this._imageRequest = 0;
|
this._imageRequest = 0;
|
||||||
this._imageQueued = false;
|
this._imageQueued = false;
|
||||||
this._imageComplete = !this.getAttribute("src");
|
this._imageInitialized = false;
|
||||||
|
this._imageCompletionDeferred = false;
|
||||||
|
this._imageComplete = typeof Deno.core.ops.op_image_metadata === "function"
|
||||||
|
? true
|
||||||
|
: !this.getAttribute("src");
|
||||||
this._imageDecoded = false;
|
this._imageDecoded = false;
|
||||||
this._imageNaturalWidth = 0;
|
this._imageNaturalWidth = 0;
|
||||||
this._imageNaturalHeight = 0;
|
this._imageNaturalHeight = 0;
|
||||||
this._imageCurrentSrc = "";
|
this._imageCurrentSrc = "";
|
||||||
this._imageDecodeWaiters = [];
|
this._imageDecodeWaiters = [];
|
||||||
|
this._refreshImageFromCache();
|
||||||
|
this._imageInitialized = true;
|
||||||
// Parser images stay lazy until script observes their lifecycle or paint
|
// Parser images stay lazy until script observes their lifecycle or paint
|
||||||
// asks for the same cache entry. Inline handlers are observers too.
|
// asks for the same cache entry. Inline handlers are observers too.
|
||||||
if (!this._imageComplete
|
if (!this._imageComplete
|
||||||
@@ -3982,7 +4014,10 @@ class HTMLImageElement extends Element {
|
|||||||
setAttribute(name, value) {
|
setAttribute(name, value) {
|
||||||
const normalized = String(name).toLowerCase();
|
const normalized = String(name).toLowerCase();
|
||||||
super.setAttribute(name, value);
|
super.setAttribute(name, value);
|
||||||
if (normalized === "src") this._imageSourceChanged();
|
if (normalized === "src" || normalized === "srcset" || normalized === "sizes"
|
||||||
|
|| normalized === "data-src" || normalized === "data-srcset") {
|
||||||
|
this._imageSourceChanged();
|
||||||
|
}
|
||||||
else if ((normalized === "onload" || normalized === "onerror")
|
else if ((normalized === "onload" || normalized === "onerror")
|
||||||
&& !this._imageComplete) this._queueImageRequest();
|
&& !this._imageComplete) this._queueImageRequest();
|
||||||
}
|
}
|
||||||
@@ -3990,7 +4025,10 @@ class HTMLImageElement extends Element {
|
|||||||
removeAttribute(name) {
|
removeAttribute(name) {
|
||||||
const normalized = String(name).toLowerCase();
|
const normalized = String(name).toLowerCase();
|
||||||
super.removeAttribute(name);
|
super.removeAttribute(name);
|
||||||
if (normalized === "src") this._imageSourceChanged();
|
if (normalized === "src" || normalized === "srcset" || normalized === "sizes"
|
||||||
|
|| normalized === "data-src" || normalized === "data-srcset") {
|
||||||
|
this._imageSourceChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
decode() {
|
decode() {
|
||||||
@@ -4007,27 +4045,33 @@ class HTMLImageElement extends Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_imageSourceChanged() {
|
_imageSourceChanged() {
|
||||||
|
this._adoptImageCandidate("");
|
||||||
|
this._imageCompletionDeferred = true;
|
||||||
|
this._refreshImageFromCache(true);
|
||||||
|
if (!this._imageComplete) this._queueImageRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
_adoptImageCandidate(currentSrc) {
|
||||||
this._rejectImageDecodes();
|
this._rejectImageDecodes();
|
||||||
this._imageRequest++;
|
this._imageRequest++;
|
||||||
this._imageQueued = false;
|
this._imageQueued = false;
|
||||||
this._imageNaturalWidth = 0;
|
this._imageNaturalWidth = 0;
|
||||||
this._imageNaturalHeight = 0;
|
this._imageNaturalHeight = 0;
|
||||||
this._imageDecoded = false;
|
this._imageDecoded = false;
|
||||||
this._imageCurrentSrc = "";
|
this._imageCurrentSrc = currentSrc ? String(currentSrc) : "";
|
||||||
this._imageComplete = !this.getAttribute("src");
|
this._imageComplete = !this._imageCurrentSrc;
|
||||||
if (!this._imageComplete) this._queueImageRequest();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_queueImageRequest() {
|
_queueImageRequest() {
|
||||||
if (this._imageQueued || this._imageComplete) return;
|
if (this._imageQueued || this._imageComplete) return;
|
||||||
this._imageQueued = true;
|
this._imageQueued = true;
|
||||||
const request = this._imageRequest;
|
const request = this._imageRequest;
|
||||||
Promise.resolve().then(() => {
|
setTimeout(() => {
|
||||||
this._imageQueued = false;
|
this._imageQueued = false;
|
||||||
if (request === this._imageRequest && !this._imageComplete) {
|
if (request === this._imageRequest && !this._imageComplete) {
|
||||||
this._runImageRequest(request);
|
this._runImageRequest(request);
|
||||||
}
|
}
|
||||||
});
|
}, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
_runImageRequest(request) {
|
_runImageRequest(request) {
|
||||||
@@ -4049,23 +4093,51 @@ class HTMLImageElement extends Element {
|
|||||||
this._applyImageMetadata(metadata, request, true);
|
this._applyImageMetadata(metadata, request, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
_refreshImageFromCache() {
|
_refreshImageFromCache(deferCompletion) {
|
||||||
if (this._imageComplete || !this.getAttribute("src")) return;
|
|
||||||
try {
|
try {
|
||||||
const op = Deno.core.ops.op_image_metadata;
|
const op = Deno.core.ops.op_image_metadata;
|
||||||
if (typeof op !== "function") return;
|
if (typeof op !== "function") return;
|
||||||
const metadata = JSON.parse(op(this._nid >>> 0, true));
|
const metadata = JSON.parse(op(this._nid >>> 0, true));
|
||||||
if (!metadata || metadata.state === "pending") return;
|
if (!metadata) return;
|
||||||
|
const selected = metadata.currentSrc ? String(metadata.currentSrc) : "";
|
||||||
|
if (selected !== this._imageCurrentSrc) {
|
||||||
|
this._adoptImageCandidate(selected);
|
||||||
|
// A live candidate switch is a new request even when paint retained
|
||||||
|
// the candidate bytes. A cache-only getter must not synchronously
|
||||||
|
// complete it and swallow the later load/error event.
|
||||||
|
if (this._imageInitialized && selected) {
|
||||||
|
this._imageCompletionDeferred = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (metadata.state === "pending") {
|
||||||
|
if (selected && this._imageComplete) {
|
||||||
|
this._adoptImageCandidate(selected);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((deferCompletion || this._imageCompletionDeferred) && selected) {
|
||||||
|
this._imageComplete = false;
|
||||||
|
this._imageDecoded = false;
|
||||||
|
this._imageNaturalWidth = 0;
|
||||||
|
this._imageNaturalHeight = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._applyImageMetadata(metadata, this._imageRequest, false);
|
this._applyImageMetadata(metadata, this._imageRequest, false);
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyImageMetadata(metadata, request, dispatchEvent) {
|
_applyImageMetadata(metadata, request, dispatchEvent) {
|
||||||
if (request !== this._imageRequest) return;
|
if (request !== this._imageRequest) return;
|
||||||
this._imageComplete = true;
|
const selected = metadata && metadata.currentSrc
|
||||||
this._imageCurrentSrc = metadata && metadata.currentSrc
|
|
||||||
? String(metadata.currentSrc)
|
? String(metadata.currentSrc)
|
||||||
: this.src;
|
: "";
|
||||||
|
if (selected !== this._imageCurrentSrc) {
|
||||||
|
this._adoptImageCandidate(selected);
|
||||||
|
request = this._imageRequest;
|
||||||
|
}
|
||||||
|
this._imageCompletionDeferred = false;
|
||||||
|
this._imageComplete = true;
|
||||||
|
this._imageCurrentSrc = selected || this.src;
|
||||||
const width = Number(metadata && metadata.width);
|
const width = Number(metadata && metadata.width);
|
||||||
const height = Number(metadata && metadata.height);
|
const height = Number(metadata && metadata.height);
|
||||||
const loaded = !!(metadata && metadata.ok)
|
const loaded = !!(metadata && metadata.ok)
|
||||||
|
|||||||
@@ -2115,52 +2115,87 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
|
|||||||
let shared = state.borrow::<SharedState>().clone();
|
let shared = state.borrow::<SharedState>().clone();
|
||||||
let mut gs = shared.borrow_mut();
|
let mut gs = shared.borrow_mut();
|
||||||
let node_id = NodeId::new(nid);
|
let node_id = NodeId::new(nid);
|
||||||
let src = gs.dom.as_ref().and_then(|dom| {
|
let is_image = gs.dom.as_ref().is_some_and(|dom| {
|
||||||
let node = dom.get_node(node_id)?;
|
dom.get_node(node_id)
|
||||||
let element = node.as_element()?;
|
.is_some_and(|node| {
|
||||||
if element.local.as_ref() != "img" {
|
node.as_element()
|
||||||
return None;
|
.is_some_and(|element| element.local.as_ref() == "img")
|
||||||
}
|
})
|
||||||
node.get_attribute("src").map(str::to_string)
|
|
||||||
});
|
});
|
||||||
let Some(src) = src.filter(|src| !src.trim().is_empty()) else {
|
if !is_image {
|
||||||
|
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
|
||||||
|
}
|
||||||
|
let base_url = document_base_url(&gs);
|
||||||
|
let viewport = gs.viewport;
|
||||||
|
let ObscuraState {
|
||||||
|
dom,
|
||||||
|
render_resources,
|
||||||
|
..
|
||||||
|
} = &mut *gs;
|
||||||
|
let Some(dom) = dom.as_ref() else {
|
||||||
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
|
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
|
||||||
};
|
};
|
||||||
let base_url = document_base_url(&gs);
|
let (current_src, density, known, dimensions) = if cached_only {
|
||||||
let metadata = if cached_only {
|
match render_resources.cached_image_element_metadata(
|
||||||
match gs
|
dom,
|
||||||
.render_resources
|
node_id,
|
||||||
.cached_image_metadata(&src, base_url.as_deref())
|
viewport,
|
||||||
{
|
base_url.as_deref(),
|
||||||
None => {
|
) {
|
||||||
return serde_json::json!({ "state": "pending" }).to_string();
|
|
||||||
}
|
|
||||||
Some(metadata) => metadata,
|
Some(metadata) => metadata,
|
||||||
|
None => {
|
||||||
|
return serde_json::json!({
|
||||||
|
"state": "error",
|
||||||
|
"ok": false,
|
||||||
|
"currentSrc": "",
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
gs.render_resources
|
match render_resources.image_element_metadata(
|
||||||
.image_metadata(&src, base_url.as_deref())
|
dom,
|
||||||
|
node_id,
|
||||||
|
viewport,
|
||||||
|
base_url.as_deref(),
|
||||||
|
) {
|
||||||
|
Some((current_src, density, dimensions)) => {
|
||||||
|
(current_src, density, true, dimensions)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return serde_json::json!({
|
||||||
|
"state": "error",
|
||||||
|
"ok": false,
|
||||||
|
"currentSrc": "",
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match metadata {
|
if !known {
|
||||||
Some((current_src, width, height)) => serde_json::json!({
|
return serde_json::json!({
|
||||||
|
"state": "pending",
|
||||||
|
"currentSrc": current_src,
|
||||||
|
"density": density,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
match dimensions {
|
||||||
|
Some((width, height)) => serde_json::json!({
|
||||||
"state": "loaded",
|
"state": "loaded",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"currentSrc": current_src,
|
"currentSrc": current_src,
|
||||||
|
"density": density,
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
None => {
|
None => {
|
||||||
let current_src = base_url
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|base| url::Url::parse(base).ok())
|
|
||||||
.and_then(|base| base.join(&src).ok())
|
|
||||||
.map(|url| url.to_string())
|
|
||||||
.unwrap_or(src);
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"state": "error",
|
"state": "error",
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"currentSrc": current_src,
|
"currentSrc": current_src,
|
||||||
|
"density": density,
|
||||||
})
|
})
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4455,6 +4455,176 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn responsive_picture_lifecycle_tracks_viewport_density_and_source_media() {
|
||||||
|
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
let seen = requests.clone();
|
||||||
|
let png = two_by_three_png();
|
||||||
|
let mut rt = parser_image_runtime(
|
||||||
|
r#"
|
||||||
|
<picture>
|
||||||
|
<source type="image/avif" srcset="unsupported.avif">
|
||||||
|
<source id="wide-source" media="(min-width: 800px)"
|
||||||
|
srcset="wide.png 2x">
|
||||||
|
<source media="(max-width: 799px)" srcset="narrow.png">
|
||||||
|
<img id="responsive-picture" src="fallback.png">
|
||||||
|
</picture>
|
||||||
|
"#,
|
||||||
|
move |url: &str| {
|
||||||
|
seen.lock().unwrap().push(url.to_string());
|
||||||
|
Some(png.clone())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
rt.set_viewport(1000.0, 600.0);
|
||||||
|
rt.execute_script(
|
||||||
|
"observe-responsive-picture",
|
||||||
|
r#"
|
||||||
|
globalThis.__pictureLoads = [];
|
||||||
|
const pictureImage = document.getElementById("responsive-picture");
|
||||||
|
pictureImage.addEventListener("load", () => {
|
||||||
|
__pictureLoads.push(pictureImage.currentSrc);
|
||||||
|
});
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
rt.run_event_loop_bounded(100).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate(
|
||||||
|
"[pictureImage.currentSrc, pictureImage.naturalWidth, \
|
||||||
|
pictureImage.naturalHeight, __pictureLoads]"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!([
|
||||||
|
"http://example.com/page/wide.png",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
["http://example.com/page/wide.png"]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// A live viewport change re-runs the renderer's media/source
|
||||||
|
// selection. The cache-only complete getter must report pending but
|
||||||
|
// must not perform the load itself.
|
||||||
|
rt.set_viewport(600.0, 600.0);
|
||||||
|
assert_eq!(rt.evaluate("pictureImage.complete").unwrap(), serde_json::json!(false));
|
||||||
|
assert_eq!(requests.lock().unwrap().len(), 1);
|
||||||
|
rt.run_event_loop_bounded(100).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate(
|
||||||
|
"[pictureImage.currentSrc, pictureImage.naturalWidth, \
|
||||||
|
pictureImage.naturalHeight, __pictureLoads]"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!([
|
||||||
|
"http://example.com/page/narrow.png",
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
[
|
||||||
|
"http://example.com/page/wide.png",
|
||||||
|
"http://example.com/page/narrow.png"
|
||||||
|
]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mutating a <source> selection input invalidates its associated img.
|
||||||
|
// The wide bytes are already shared in the render cache, but lifecycle
|
||||||
|
// completion remains task-queued and emits one new load event.
|
||||||
|
rt.execute_script(
|
||||||
|
"mutate-picture-source",
|
||||||
|
r#"
|
||||||
|
document.getElementById("wide-source").setAttribute("media", "all");
|
||||||
|
globalThis.__pictureCompleteAfterSourceMutation = pictureImage.complete;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("__pictureCompleteAfterSourceMutation").unwrap(),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
rt.run_event_loop_bounded(100).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("[pictureImage.currentSrc, __pictureLoads]").unwrap(),
|
||||||
|
serde_json::json!([
|
||||||
|
"http://example.com/page/wide.png",
|
||||||
|
[
|
||||||
|
"http://example.com/page/wide.png",
|
||||||
|
"http://example.com/page/narrow.png",
|
||||||
|
"http://example.com/page/wide.png"
|
||||||
|
]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*requests.lock().unwrap(),
|
||||||
|
vec![
|
||||||
|
"http://example.com/page/wide.png".to_string(),
|
||||||
|
"http://example.com/page/narrow.png".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn responsive_srcset_sizes_uses_renderer_selected_current_src() {
|
||||||
|
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
let seen = requests.clone();
|
||||||
|
let png = two_by_three_png();
|
||||||
|
let mut rt = parser_image_runtime(
|
||||||
|
r#"<img id="responsive-srcset" src="fallback.png"
|
||||||
|
srcset="small.png 400w, large.png 800w" sizes="400px">"#,
|
||||||
|
move |url: &str| {
|
||||||
|
seen.lock().unwrap().push(url.to_string());
|
||||||
|
Some(png.clone())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
rt.execute_script(
|
||||||
|
"observe-responsive-srcset",
|
||||||
|
r#"
|
||||||
|
globalThis.__srcsetLoads = [];
|
||||||
|
const srcsetImage = document.getElementById("responsive-srcset");
|
||||||
|
srcsetImage.addEventListener("load", () => {
|
||||||
|
__srcsetLoads.push(srcsetImage.currentSrc);
|
||||||
|
});
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
rt.run_event_loop_bounded(100).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("[srcsetImage.currentSrc, __srcsetLoads]").unwrap(),
|
||||||
|
serde_json::json!([
|
||||||
|
"http://example.com/page/small.png",
|
||||||
|
["http://example.com/page/small.png"]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
change_srcset_image_sizes(&mut rt);
|
||||||
|
assert_eq!(rt.evaluate("srcsetImage.complete").unwrap(), serde_json::json!(false));
|
||||||
|
rt.run_event_loop_bounded(100).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("[srcsetImage.currentSrc, __srcsetLoads]").unwrap(),
|
||||||
|
serde_json::json!([
|
||||||
|
"http://example.com/page/large.png",
|
||||||
|
[
|
||||||
|
"http://example.com/page/small.png",
|
||||||
|
"http://example.com/page/large.png"
|
||||||
|
]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*requests.lock().unwrap(),
|
||||||
|
vec![
|
||||||
|
"http://example.com/page/small.png".to_string(),
|
||||||
|
"http://example.com/page/large.png".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
fn change_srcset_image_sizes(rt: &mut ObscuraJsRuntime) {
|
||||||
|
rt.execute_script("change-responsive-sizes", r#"srcsetImage.sizes = "800px";"#)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression for #105: `HTMLFormElement` must expose `.elements` so
|
/// Regression for #105: `HTMLFormElement` must expose `.elements` so
|
||||||
/// frameworks that probe form field collections work.
|
/// frameworks that probe form field collections work.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -162,6 +162,49 @@ impl RenderResourceCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Select and inspect the resource for one live `<img>` using the same
|
||||||
|
/// `picture`/`srcset`/`sizes` algorithm as `collect_image_intrinsics`.
|
||||||
|
/// Dimensions are returned in CSS pixels after candidate-density scaling.
|
||||||
|
/// A selected URL with `None` dimensions is an authoritative load/decode
|
||||||
|
/// failure.
|
||||||
|
pub fn image_element_metadata(
|
||||||
|
&mut self,
|
||||||
|
tree: &DomTree,
|
||||||
|
id: obscura_dom::tree::NodeId,
|
||||||
|
viewport: (f32, f32),
|
||||||
|
base_url: Option<&str>,
|
||||||
|
) -> Option<(String, f32, Option<(f32, f32)>)> {
|
||||||
|
let (src, density) = resolve_img_url(tree, id, viewport)?;
|
||||||
|
let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src);
|
||||||
|
let dimensions = fetch_bytes(&resolved_url, None, self)
|
||||||
|
.and_then(|bytes| image_metadata_from_bytes(&bytes))
|
||||||
|
.map(|(width, height)| (width / density, height / density));
|
||||||
|
Some((resolved_url, density, dimensions))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache-only counterpart to [`Self::image_element_metadata`]. The boolean
|
||||||
|
/// is false only when the selected candidate has no live cache outcome;
|
||||||
|
/// callers may queue the loading form without ever blocking a getter.
|
||||||
|
pub fn cached_image_element_metadata(
|
||||||
|
&self,
|
||||||
|
tree: &DomTree,
|
||||||
|
id: obscura_dom::tree::NodeId,
|
||||||
|
viewport: (f32, f32),
|
||||||
|
base_url: Option<&str>,
|
||||||
|
) -> Option<(String, f32, bool, Option<(f32, f32)>)> {
|
||||||
|
let (src, density) = resolve_img_url(tree, id, viewport)?;
|
||||||
|
let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src);
|
||||||
|
match self.cached_image_metadata(&resolved_url, None) {
|
||||||
|
None => Some((resolved_url, density, false, None)),
|
||||||
|
Some(dimensions) => Some((
|
||||||
|
resolved_url,
|
||||||
|
density,
|
||||||
|
true,
|
||||||
|
dimensions.map(|(_, width, height)| (width / density, height / density)),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_or_load(&mut self, url: &str) -> Option<Arc<[u8]>> {
|
fn get_or_load(&mut self, url: &str) -> Option<Arc<[u8]>> {
|
||||||
if let Some(entry) = self.entries.get(url) {
|
if let Some(entry) = self.entries.get(url) {
|
||||||
match entry {
|
match entry {
|
||||||
|
|||||||
Reference in new issue
Block a user