Connect image elements to render resources
This commit is contained in:
1 parent
476191d9c8
commit
c5210b1684
4 files changed
+620
-34
No files matched your search
@@ -2048,6 +2048,7 @@ pub fn build_extension() -> Extension {
|
||||
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
ops.push(op_image_metadata());
|
||||
ops.push(op_layout_geometry());
|
||||
ops.push(op_layout_metrics());
|
||||
ops.push(op_scroll_offset());
|
||||
@@ -2102,6 +2103,70 @@ pub(crate) fn ensure_prepared_render(
|
||||
state.prepared_render.as_ref()
|
||||
}
|
||||
|
||||
/// Probe one ordinary `<img src>` through the renderer's page-scoped resource
|
||||
/// cache. This is deliberately a synchronous render-only op: the current
|
||||
/// renderer loader is synchronous too, and sharing it is what guarantees that
|
||||
/// HTMLImageElement lifecycle state and the subsequent paint see the same
|
||||
/// success/failure and retained bytes.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
|
||||
let shared = state.borrow::<SharedState>().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 Some(src) = src.filter(|src| !src.trim().is_empty()) 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();
|
||||
}
|
||||
Some(metadata) => metadata,
|
||||
}
|
||||
} else {
|
||||
gs.render_resources
|
||||
.image_metadata(&src, base_url.as_deref())
|
||||
};
|
||||
match metadata {
|
||||
Some((current_src, width, height)) => serde_json::json!({
|
||||
"state": "loaded",
|
||||
"ok": true,
|
||||
"currentSrc": current_src,
|
||||
"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,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
|
||||
state.scroll_offset = ensure_prepared_render(state)
|
||||
|
||||
@@ -4218,6 +4218,243 @@ mod tests {
|
||||
assert_eq!(rt.evaluate("document.links.length").unwrap().as_f64().unwrap() as i64, 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn parser_image_runtime(
|
||||
html: &str,
|
||||
loader: impl obscura_render::RenderResourceLoader + 'static,
|
||||
) -> ObscuraJsRuntime {
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(parse_html(html));
|
||||
rt.set_url("http://example.com/page/index.html");
|
||||
rt.state.borrow_mut().render_resources =
|
||||
obscura_render::RenderResourceCache::with_loader(loader);
|
||||
rt.run_page_init();
|
||||
rt
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn two_by_three_png() -> Vec<u8> {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6B\
|
||||
AAAAFklEQVR4nGP8z8Dwn4GBgYGJAQrgDAAxOwIE7x6DkQAAAABJRU5ErkJggg=="
|
||||
.replace(char::is_whitespace, ""),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn parser_image_lifecycle_uses_shared_render_resource() {
|
||||
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="hero" src="../assets/hero.png">"#,
|
||||
move |_url: &str| {
|
||||
loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Some(png.clone())
|
||||
},
|
||||
);
|
||||
rt.execute_script(
|
||||
"observe-parser-image",
|
||||
r#"
|
||||
globalThis.__imageEvents = [];
|
||||
globalThis.__decodeState = "pending";
|
||||
const image = document.getElementById("hero");
|
||||
globalThis.__imageInitial = [
|
||||
image instanceof HTMLImageElement,
|
||||
image.complete,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight
|
||||
];
|
||||
image.addEventListener("load", () => __imageEvents.push("load"));
|
||||
image.addEventListener("error", () => __imageEvents.push("error"));
|
||||
image.decode().then(
|
||||
() => { __decodeState = "resolved"; },
|
||||
error => { __decodeState = error.name; }
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__imageInitial").unwrap(),
|
||||
serde_json::json!([true, false, 0, 0])
|
||||
);
|
||||
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
r#"[
|
||||
image.complete,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
image.currentSrc,
|
||||
__imageEvents,
|
||||
__decodeState
|
||||
]"#,
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([
|
||||
true,
|
||||
2,
|
||||
3,
|
||||
"http://example.com/assets/hero.png",
|
||||
["load"],
|
||||
"resolved"
|
||||
])
|
||||
);
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
|
||||
// A subsequent request for the same URL is served by the retained
|
||||
// renderer bytes rather than calling the loader again.
|
||||
rt.execute_script("reload-image", r#"image.src = "../assets/hero.png";"#)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn parser_image_failure_completes_and_rejects_decode() {
|
||||
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let loader_calls = calls.clone();
|
||||
let mut rt = parser_image_runtime(
|
||||
r#"<img id="broken" src="missing.png">"#,
|
||||
move |_url: &str| {
|
||||
loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
None
|
||||
},
|
||||
);
|
||||
rt.execute_script(
|
||||
"observe-broken-image",
|
||||
r#"
|
||||
globalThis.__brokenEvents = [];
|
||||
globalThis.__brokenDecode = "pending";
|
||||
const broken = document.getElementById("broken");
|
||||
broken.onload = () => __brokenEvents.push("load");
|
||||
broken.onerror = () => __brokenEvents.push("error");
|
||||
broken.decode().then(
|
||||
() => { __brokenDecode = "resolved"; },
|
||||
error => { __brokenDecode = error.name; }
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
"[broken.complete, broken.naturalWidth, broken.naturalHeight, \
|
||||
__brokenEvents, __brokenDecode]"
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([true, 0, 0, ["error"], "EncodingError"])
|
||||
);
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn parser_image_first_getter_observes_prepare_seeded_cache_synchronously() {
|
||||
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="cached" src="cached.png">"#,
|
||||
move |_url: &str| {
|
||||
loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Some(png.clone())
|
||||
},
|
||||
);
|
||||
{
|
||||
let mut state = rt.state.borrow_mut();
|
||||
assert!(ensure_prepared_render(&mut state).is_some());
|
||||
}
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
|
||||
// Constructing the JS wrapper happens here, after prepare_dom loaded
|
||||
// the resource. The first complete getter must see that cache hit
|
||||
// immediately; it must not briefly regress to pending/zero.
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
r#"(() => {
|
||||
const cached = document.getElementById("cached");
|
||||
return [
|
||||
cached.complete,
|
||||
cached.naturalWidth,
|
||||
cached.naturalHeight,
|
||||
cached.currentSrc
|
||||
];
|
||||
})()"#,
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([
|
||||
true,
|
||||
2,
|
||||
3,
|
||||
"http://example.com/page/cached.png"
|
||||
])
|
||||
);
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn parser_image_source_replacement_cancels_queued_completion() {
|
||||
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let seen = requests.clone();
|
||||
let first = two_by_three_png();
|
||||
use base64::Engine as _;
|
||||
let second = base64::engine::general_purpose::STANDARD
|
||||
.decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAFCAYAAABirU3b\
|
||||
AAAAFUlEQVR4nGNk+M/wnwEJMDGgATIEAKVaAgg/Jbt7AAAAAElFTkSuQmCC"
|
||||
.replace(char::is_whitespace, ""),
|
||||
)
|
||||
.unwrap();
|
||||
let mut rt = parser_image_runtime(
|
||||
r#"<img id="swap" src="old.png">"#,
|
||||
move |url: &str| {
|
||||
seen.lock().unwrap().push(url.to_string());
|
||||
if url.ends_with("/new.png") {
|
||||
Some(second.clone())
|
||||
} else {
|
||||
Some(first.clone())
|
||||
}
|
||||
},
|
||||
);
|
||||
rt.execute_script(
|
||||
"replace-image-source",
|
||||
r#"
|
||||
globalThis.__swapEvents = [];
|
||||
const swap = document.getElementById("swap");
|
||||
swap.addEventListener("load", () => __swapEvents.push(swap.currentSrc));
|
||||
swap.src = "new.png";
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
"[swap.complete, swap.naturalWidth, swap.naturalHeight, \
|
||||
swap.currentSrc, __swapEvents]"
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([
|
||||
true,
|
||||
4,
|
||||
5,
|
||||
"http://example.com/page/new.png",
|
||||
["http://example.com/page/new.png"]
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
*requests.lock().unwrap(),
|
||||
vec!["http://example.com/page/new.png".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for #105: `HTMLFormElement` must expose `.elements` so
|
||||
/// frameworks that probe form field collections work.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user