diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index e97d904..45c0861 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1925,6 +1925,16 @@ class Element extends Node { if (n === "style") this._style._replaceFromAttribute(value); if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev); 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) { 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); 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) { ns = String(ns == null ? "" : ns); n = String(n); @@ -3885,20 +3911,26 @@ function _imageEncodingError() { } // HTMLImageElement is backed by the same retained resource cache used by -// layout/paint. The render-only native op fetches and sniffs the ordinary -// `src`; bootstrap owns only the observable request state and event timing. -// Responsive candidate selection remains explicitly deferred to the renderer. +// layout/paint. The render-only native op owns responsive candidate selection, +// fetching, and metadata sniffing; bootstrap owns only the observable request +// state and event timing. class HTMLImageElement extends Element { constructor(nid) { super(nid); this._imageRequest = 0; 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._imageNaturalWidth = 0; this._imageNaturalHeight = 0; this._imageCurrentSrc = ""; this._imageDecodeWaiters = []; + this._refreshImageFromCache(); + this._imageInitialized = true; // Parser images stay lazy until script observes their lifecycle or paint // asks for the same cache entry. Inline handlers are observers too. if (!this._imageComplete @@ -3982,7 +4014,10 @@ class HTMLImageElement extends Element { setAttribute(name, value) { const normalized = String(name).toLowerCase(); 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") && !this._imageComplete) this._queueImageRequest(); } @@ -3990,7 +4025,10 @@ class HTMLImageElement extends Element { removeAttribute(name) { const normalized = String(name).toLowerCase(); super.removeAttribute(name); - if (normalized === "src") this._imageSourceChanged(); + if (normalized === "src" || normalized === "srcset" || normalized === "sizes" + || normalized === "data-src" || normalized === "data-srcset") { + this._imageSourceChanged(); + } } decode() { @@ -4007,27 +4045,33 @@ class HTMLImageElement extends Element { } _imageSourceChanged() { + this._adoptImageCandidate(""); + this._imageCompletionDeferred = true; + this._refreshImageFromCache(true); + if (!this._imageComplete) this._queueImageRequest(); + } + + _adoptImageCandidate(currentSrc) { this._rejectImageDecodes(); this._imageRequest++; this._imageQueued = false; this._imageNaturalWidth = 0; this._imageNaturalHeight = 0; this._imageDecoded = false; - this._imageCurrentSrc = ""; - this._imageComplete = !this.getAttribute("src"); - if (!this._imageComplete) this._queueImageRequest(); + this._imageCurrentSrc = currentSrc ? String(currentSrc) : ""; + this._imageComplete = !this._imageCurrentSrc; } _queueImageRequest() { if (this._imageQueued || this._imageComplete) return; this._imageQueued = true; const request = this._imageRequest; - Promise.resolve().then(() => { + setTimeout(() => { this._imageQueued = false; if (request === this._imageRequest && !this._imageComplete) { this._runImageRequest(request); } - }); + }, 1); } _runImageRequest(request) { @@ -4049,23 +4093,51 @@ class HTMLImageElement extends Element { this._applyImageMetadata(metadata, request, true); } - _refreshImageFromCache() { - if (this._imageComplete || !this.getAttribute("src")) return; + _refreshImageFromCache(deferCompletion) { try { const op = Deno.core.ops.op_image_metadata; if (typeof op !== "function") return; 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); } catch (_error) {} } _applyImageMetadata(metadata, request, dispatchEvent) { if (request !== this._imageRequest) return; - this._imageComplete = true; - this._imageCurrentSrc = metadata && metadata.currentSrc + const selected = metadata && 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 height = Number(metadata && metadata.height); const loaded = !!(metadata && metadata.ok) diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 2d29d2a..df2eaa4 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -2115,52 +2115,87 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String { let shared = state.borrow::().clone(); let mut gs = shared.borrow_mut(); let node_id = NodeId::new(nid); - let src = gs.dom.as_ref().and_then(|dom| { - let node = dom.get_node(node_id)?; - let element = node.as_element()?; - if element.local.as_ref() != "img" { - return None; - } - node.get_attribute("src").map(str::to_string) + let is_image = gs.dom.as_ref().is_some_and(|dom| { + dom.get_node(node_id) + .is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "img") + }) }); - 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(); }; - let base_url = document_base_url(&gs); - let metadata = if cached_only { - match gs - .render_resources - .cached_image_metadata(&src, base_url.as_deref()) - { - None => { - return serde_json::json!({ "state": "pending" }).to_string(); - } + let (current_src, density, known, dimensions) = if cached_only { + match render_resources.cached_image_element_metadata( + dom, + node_id, + viewport, + base_url.as_deref(), + ) { Some(metadata) => metadata, + None => { + return serde_json::json!({ + "state": "error", + "ok": false, + "currentSrc": "", + }) + .to_string(); + } } } else { - gs.render_resources - .image_metadata(&src, base_url.as_deref()) + match render_resources.image_element_metadata( + 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 { - Some((current_src, width, height)) => serde_json::json!({ + if !known { + return serde_json::json!({ + "state": "pending", + "currentSrc": current_src, + "density": density, + }) + .to_string(); + } + match dimensions { + Some((width, height)) => serde_json::json!({ "state": "loaded", "ok": true, "currentSrc": current_src, + "density": density, "width": width, "height": height, }) .to_string(), 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!({ "state": "error", "ok": false, "currentSrc": current_src, + "density": density, }) .to_string() } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 25e95f7..bb5a909 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -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#" + + + + + + + "#, + 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 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#""#, + 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 /// frameworks that probe form field collections work. #[test] diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 79c644e..5dce5bc 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -162,6 +162,49 @@ impl RenderResourceCache { } } + /// Select and inspect the resource for one live `` 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> { if let Some(entry) = self.entries.get(url) { match entry {