diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/events.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/events.rs index 60effcc6bb..1932073365 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/events.rs @@ -271,26 +271,35 @@ pub(super) fn font_face_loading_started<'s>( face: v8::Local<'s, v8::Object>, ) { for owner in font_face_set_owner_snapshot(scope, face) { - if super::loading::string_slot(scope, owner, FONT_FACE_SET_STATUS_SLOT) == "loading" { - continue; - } - set_font_face_set_status(scope, owner, "loading"); - let resolver = v8::PromiseResolver::new(scope).expect("FontFaceSet ready resolver"); - set_private_value(scope, owner, READY_RESOLVER, resolver.into()); - set_private_value( - scope, - owner, - FONT_FACE_SET_READY_SLOT, - resolver.get_promise(scope).into(), - ); - for slot in [LOADED_FACES, FAILED_FACES] { - let array = v8::Array::new(scope, 0); - set_private_value(scope, owner, slot, array.into()); - } - let _ = dispatch_font_face_set_event(scope, owner, "loading", None); + font_face_set_loading_started(scope, owner); } } +/// CSS resource discovery and JS FontFace.load share one loading cycle and +/// one ready promise. Additional arrivals must not replace a pending promise. +pub(super) fn font_face_set_loading_started<'s>( + scope: &mut v8::PinScope<'s, '_>, + owner: v8::Local<'s, v8::Object>, +) { + if super::loading::string_slot(scope, owner, FONT_FACE_SET_STATUS_SLOT) == "loading" { + return; + } + set_font_face_set_status(scope, owner, "loading"); + let resolver = v8::PromiseResolver::new(scope).expect("FontFaceSet ready resolver"); + set_private_value(scope, owner, READY_RESOLVER, resolver.into()); + set_private_value( + scope, + owner, + FONT_FACE_SET_READY_SLOT, + resolver.get_promise(scope).into(), + ); + for slot in [LOADED_FACES, FAILED_FACES] { + let array = v8::Array::new(scope, 0); + set_private_value(scope, owner, slot, array.into()); + } + let _ = dispatch_font_face_set_event(scope, owner, "loading", None); +} + pub(super) fn notify_font_face_set_owners_of_load<'s>( scope: &mut v8::PinScope<'s, '_>, face: v8::Local<'s, v8::Object>, @@ -334,6 +343,11 @@ pub(super) fn finish_font_set_if_idle<'s>( if super::loading::string_slot(scope, owner, FONT_FACE_SET_STATUS_SLOT) != "loading" { return; } + // Native CSS requests can be pending before a JS wrapper is projected. + // Conversely, the native CSS queue does not contain JS-created faces. + if super::storage::document_font_face_set_has_pending_requests(scope, owner) { + return; + } let pending = super::storage::font_face_set_faces_array(scope, owner).is_some_and(|faces| { (0..faces.length()).any(|index| { faces @@ -348,16 +362,23 @@ pub(super) fn finish_font_set_if_idle<'s>( if pending { return; } - set_font_face_set_status(scope, owner, "loaded"); - if let Some(resolver) = super::loading::resolver_from_slot(scope, owner, READY_RESOLVER) { - let _ = resolver.resolve(scope, owner.into()); - } - for (slot, event) in [ + let events = [ (LOADED_FACES, "loadingdone"), (FAILED_FACES, "loadingerror"), - ] { - if let Some(array) = get_private_value(scope, owner, slot) - .and_then(|v| v8::Local::::try_from(v).ok()) + ] + .map(|(slot, event)| { + let faces = get_private_value(scope, owner, slot) + .and_then(|v| v8::Local::::try_from(v).ok()); + (faces, event) + }); + set_font_face_set_status(scope, owner, "loaded"); + if let Some(resolver) = super::loading::resolver_from_slot(scope, owner, READY_RESOLVER) { + set_private_value(scope, owner, READY_RESOLVER, v8::undefined(scope).into()); + let _ = resolver.resolve(scope, owner.into()); + } + // A loadingdone handler may begin another cycle and replace its arrays. + for (faces, event) in events { + if let Some(array) = faces && (event == "loadingdone" || array.length() > 0) { let _ = dispatch_font_face_set_event(scope, owner, event, Some(array)); diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face.rs index 80cf5bb7d3..9b36fe9116 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face.rs @@ -313,7 +313,8 @@ pub(in crate::context_bootstrap) fn font_face_constructor_callback<'s>( let this = args.this(); let (source, sources, binary) = match parsed.source { FontFaceConstructorSource::Css(source) => { - let sources = moli_css_parse::parse_font_face_sources(&source); + let sources = moli_css_parse::parse_font_face_sources(&source) + .map(|sources| crate::network_host::resolve_font_face_sources(scope, sources)); (source, sources, None) } FontFaceConstructorSource::Binary(value) => { diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/loading.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/loading.rs index e7ec6f3931..0a95f0f7dc 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/loading.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/loading.rs @@ -97,7 +97,7 @@ pub(super) fn resolver_from_slot<'s>( object: v8::Local<'s, v8::Object>, slot: &'static str, ) -> Option> { - let value = get_private_value(scope, object, slot)?; + let value = get_private_value(scope, object, slot).filter(|value| value.is_object())?; // SAFETY: these private slots are populated only by PromiseResolver::new // above (and FontFaceSet.ready). They are never writable by page code. Some(unsafe { v8::Local::::cast_unchecked(value) }) diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs index eb61d36a3c..f08ec0b6f0 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs @@ -112,50 +112,45 @@ fn font_face_set_attribute_getter_callback<'s>( } const DOCUMENT_FONT_FACE_SET_OWNER: &str = "__moliFontFaceSetOwnerDocument"; -const DOCUMENT_FONT_FACE_SET_READY_RESOLVER: &str = "__moliDocumentFontFaceSetReadyResolver"; + +fn font_face_set_document_context<'s>( + scope: &mut v8::PinScope<'s, '_>, + fonts: v8::Local<'s, v8::Object>, +) -> Option<( + *mut crate::native_bridge::JsContextHost, + crate::document_runtime::DomHandle, +)> { + let document = get_private_value(scope, fonts, DOCUMENT_FONT_FACE_SET_OWNER) + .and_then(|value| v8::Local::::try_from(value).ok())?; + let (host_ptr, handle) = + crate::native_bridge::node_runtime_and_handle_from_object(scope, document).ok()?; + unsafe { &*host_ptr }.owner_dispatch_scope_for_node(handle)?; + Some((host_ptr, handle)) +} + +pub(super) fn document_font_face_set_has_pending_requests<'s>( + scope: &mut v8::PinScope<'s, '_>, + fonts: v8::Local<'s, v8::Object>, +) -> bool { + font_face_set_document_context(scope, fonts).is_some_and(|(host_ptr, document)| { + unsafe { &*host_ptr }.document_web_fonts_have_pending_requests(document) + }) +} fn reconcile_document_font_face_set_ready<'s>( scope: &mut v8::PinScope<'s, '_>, fonts: v8::Local<'s, v8::Object>, ) { - let Some(document) = get_private_value(scope, fonts, DOCUMENT_FONT_FACE_SET_OWNER) - .and_then(|value| v8::Local::::try_from(value).ok()) - else { + let Some((host_ptr, handle)) = font_face_set_document_context(scope, fonts) else { return; }; - let Ok((host_ptr, handle)) = - crate::native_bridge::node_runtime_and_handle_from_object(scope, document) - else { - return; - }; - if unsafe { &*host_ptr } - .owner_dispatch_scope_for_node(handle) - .is_none() - { - return; - } crate::document_runtime::DocumentRuntime::reconcile_web_fonts_for_document_in_current_scope( scope, host_ptr, handle, ); if unsafe { &*host_ptr }.document_web_fonts_have_pending_requests(handle) { - if get_private_value(scope, fonts, DOCUMENT_FONT_FACE_SET_READY_RESOLVER) - .is_some_and(|value| value.is_object()) - { - return; - } - if let Some(resolver) = v8::PromiseResolver::new(scope) { - let promise = resolver.get_promise(scope); - set_private_value( - scope, - fonts, - DOCUMENT_FONT_FACE_SET_READY_RESOLVER, - resolver.into(), - ); - set_font_face_set_slot_value(scope, fonts, FONT_FACE_SET_READY_SLOT, promise.into()); - set_font_face_set_status(scope, fonts, "loading"); - } + super::events::font_face_set_loading_started(scope, fonts); } else { - settle_document_font_face_set_ready(scope, host_ptr, handle); + super::events::finish_font_set_if_idle(scope, fonts); } } @@ -177,21 +172,7 @@ pub(crate) fn settle_document_font_face_set_ready( else { return; }; - let Some(resolver) = get_private_value(scope, fonts, DOCUMENT_FONT_FACE_SET_READY_RESOLVER) - .filter(|value| value.is_object()) - else { - return; - }; - // Only a native PromiseResolver can occupy this private slot. - let resolver = unsafe { v8::Local::::cast_unchecked(resolver) }; - set_font_face_set_status(scope, fonts, "loaded"); - set_private_value( - scope, - fonts, - DOCUMENT_FONT_FACE_SET_READY_RESOLVER, - v8::undefined(scope).into(), - ); - let _ = resolver.resolve(scope, fonts.into()); + super::events::finish_font_set_if_idle(scope, fonts); } pub(crate) fn new_font_face_set<'s>( diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index 616cd73099..f26f6ce473 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -22,7 +22,7 @@ mod text_track; mod url_helpers; mod xhr; -pub(crate) use font_face::{FontFaceFetchStart, start_font_face_fetch}; +pub(crate) use font_face::{FontFaceFetchStart, resolve_font_face_sources, start_font_face_fetch}; use moli_fetch::{Request, Response, observe_cookie_access_report_for_request}; use crate::network::ResourceRequestClient; diff --git a/moli-renderer-v8/src/network_host/font_face.rs b/moli-renderer-v8/src/network_host/font_face.rs index b482053ea0..6e4491aff2 100644 --- a/moli-renderer-v8/src/network_host/font_face.rs +++ b/moli-renderer-v8/src/network_host/font_face.rs @@ -20,6 +20,33 @@ pub(crate) enum FontFaceFetchStart { Pending, } +/// Resolve URL sources at construction, before a later base mutation or source +/// fallback. Invalid URLs remain unavailable sources, not CSS syntax errors; +/// source exhaustion is reported only when loading is requested. +pub(crate) fn resolve_font_face_sources( + scope: &mut v8::PinScope<'_, '_>, + sources: Vec, +) -> Vec { + use moli_css_parse::CssFontSource; + + let base = crate::util::context_host_ptr_from_global_bridge(scope).and_then(|host_ptr| { + let host = unsafe { &*host_ptr }; + let owner = effective_subresource_request_owner(scope, host); + subresource_api_base_url(scope, host, owner) + }); + sources + .into_iter() + .filter_map(|source| match source { + CssFontSource::Local(_) => Some(source), + CssFontSource::Url(url) => base + .as_ref() + .map_or_else(|| url::Url::parse(&url), |base| base.join(&url)) + .ok() + .map(|url| CssFontSource::Url(url.into())), + }) + .collect() +} + /// Explicit CSS Font Loading requests share document ownership, CORS and the /// renderer network queue with other resources. Never call page-defined fetch. pub(crate) fn start_font_face_fetch( diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs index 18fbf4bd63..d08ef11fca 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs @@ -2,6 +2,8 @@ use super::*; use base64::Engine; use std::time::Duration; +mod readiness; + pub(super) fn fixture_font_url() -> String { let bytes = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/readiness.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/readiness.rs new file mode 100644 index 0000000000..3edca62612 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/readiness.rs @@ -0,0 +1,255 @@ +use super::*; +use std::collections::HashMap; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +type FontGates = HashMap>; + +async fn gated_fonts( + names: &[&str], +) -> ( + String, + tokio::sync::mpsc::UnboundedReceiver, + FontGates, + tokio::task::JoinHandle<()>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let (requests_tx, requests_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut gates = HashMap::new(); + let mut releases = HashMap::new(); + for name in names { + let (tx, rx) = tokio::sync::oneshot::channel(); + gates.insert((*name).to_owned(), tx); + releases.insert((*name).to_owned(), rx); + } + let server = tokio::spawn(async move { + let mut responses = Vec::new(); + while !releases.is_empty() { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let read = stream.read(&mut buffer).await.unwrap(); + assert!(read > 0); + request.extend_from_slice(&buffer[..read]); + } + let request = String::from_utf8(request).unwrap(); + let path = request.split_whitespace().nth(1).unwrap().to_owned(); + let release = releases.remove(path.rsplit('/').next().unwrap()).unwrap(); + requests_tx.send(path).unwrap(); + responses.push(tokio::spawn(async move { + let _ = release.await; + let font = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../moli-layout/tests/fixtures/moli-ahem.woff2" + )); + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: font/woff2\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + font.len() + ); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(font).await; + })); + } + for response in responses { + response.await.unwrap(); + } + }); + (origin, requests_rx, gates, server) +} + +async fn assert_mixed_ready(js_starts_first: bool, js_finishes_first: bool) { + let (origin, mut requests, mut gates, server) = gated_fonts(&["css.woff2", "js.woff2"]).await; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + loader.set_optional_resource_fetch_mask(crate::protocol_types::OptionalResourceFetchMask::FONT); + let (mut vm, mut completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + &format!("{origin}/page"), + "AAAA", + &loader, + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + vm.eval("globalThis.fonts=document.fonts;globalThis.readyResults=[];globalThis.doneEvents=[];fonts.addEventListener('loadingdone',event=>doneEvents.push(event.fontfaces.map(face=>face.family).sort().join(',')))").unwrap(); + let start_css = format!( + r#"const style=document.createElement('style'); + style.textContent='@font-face{{font-family:AuditCSS;src:url("{origin}/css.woff2")}}'; + document.head.append(style);document.body.style.fontFamily='AuditCSS'; + globalThis.cssReady=fonts.ready; + globalThis.cssFace=[...fonts].find(face=>face.family==='AuditCSS'); + cssReady.then(value=>readyResults.push('css:'+String(value===fonts)));"# + ); + let start_js = format!( + r#"globalThis.jsFace=new FontFace('AuditJS','url("{origin}/js.woff2")'); + fonts.add(jsFace);jsFace.load();globalThis.jsReady=fonts.ready; + jsReady.then(value=>readyResults.push('js:'+String(value===fonts)));"# + ); + for (program, expected) in if js_starts_first { + [(&start_js, "/js.woff2"), (&start_css, "/css.woff2")] + } else { + [(&start_css, "/css.woff2"), (&start_js, "/js.woff2")] + } { + vm.eval(program).unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), requests.recv()) + .await + .unwrap() + .unwrap(), + expected + ); + } + assert_eq!( + vm.eval("JSON.stringify([cssFace.status,jsFace.status,cssReady===jsReady,readyResults,doneEvents])").unwrap(), + r#"["loading","loading",true,[],[]]"# + ); + let order = if js_finishes_first { + ["js.woff2", "css.woff2"] + } else { + ["css.woff2", "js.woff2"] + }; + for (index, name) in order.into_iter().enumerate() { + gates.remove(name).unwrap().send(()).unwrap(); + assert!( + tokio::time::timeout( + Duration::from_secs(2), + completions.wait_for_arrival_without_timeout() + ) + .await + .unwrap() + ); + let completion = completions.pop_next_async_subresource_event().unwrap(); + let activity = vm + .complete_async_subresource_fetch_event_body(completion) + .unwrap(); + vm.finish_async_subresource_body_checkpoint_for_test(activity) + .unwrap(); + let expected = if index == 1 { + r#"["loaded","loaded",["css:true","js:true"],["AuditCSS,AuditJS"]]"# + } else if js_finishes_first { + r#"["loading","loaded",[],[]]"# + } else { + r#"["loaded","loading",[],[]]"# + }; + // Read only captured promises/results here: another fonts.ready or + // fonts.status getter must not be needed to finish the loading cycle. + assert_eq!( + vm.eval("JSON.stringify([cssFace.status,jsFace.status,readyResults.slice().sort(),doneEvents])").unwrap(), + expected + ); + } + server.await.unwrap(); +} + +#[tokio::test] +async fn font_set_ready_waits_for_js_after_css_started_and_finished_first() { + assert_mixed_ready(false, false).await; +} + +#[tokio::test] +async fn font_set_ready_preserves_js_promise_when_css_finishes_first() { + assert_mixed_ready(true, false).await; +} + +#[tokio::test] +async fn font_set_ready_waits_for_css_after_js_finishes_first() { + assert_mixed_ready(false, true).await; +} + +#[tokio::test] +async fn font_set_ready_preserves_js_promise_when_js_finishes_first() { + assert_mixed_ready(true, true).await; +} + +#[tokio::test] +async fn font_set_ready_settles_css_started_by_explicit_font_face_load() { + let (origin, mut requests, mut gates, server) = gated_fonts(&["css.woff2"]).await; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + loader.set_optional_resource_fetch_mask(crate::protocol_types::OptionalResourceFetchMask::FONT); + let (mut vm, mut completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + &format!("{origin}/page"), + "AAAA", + &loader, + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + vm.eval(&format!( + r#" + const style=document.createElement('style'); + style.textContent='@font-face{{font-family:AuditCSS;src:url("{origin}/css.woff2")}}'; + document.head.append(style); + globalThis.fonts=document.fonts;globalThis.face=[...fonts][0]; + face.load();globalThis.ready=fonts.ready;globalThis.readyResolved=false; + ready.then(value=>readyResolved=value===fonts); + "# + )) + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), requests.recv()) + .await + .unwrap() + .unwrap(), + "/css.woff2" + ); + gates.remove("css.woff2").unwrap().send(()).unwrap(); + assert!( + tokio::time::timeout( + Duration::from_secs(2), + completions.wait_for_arrival_without_timeout() + ) + .await + .unwrap() + ); + let completion = completions.pop_next_async_subresource_event().unwrap(); + let activity = vm + .complete_async_subresource_fetch_event_body(completion) + .unwrap(); + vm.finish_async_subresource_body_checkpoint_for_test(activity) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify([face.status,readyResolved])") + .unwrap(), + r#"["loaded",true]"# + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn font_face_url_is_frozen_before_base_href_changes() { + let (origin, mut requests, mut gates, server) = gated_fonts(&["font.woff2"]).await; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let (mut vm, mut completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + &format!("{origin}/page"), + "", + &loader, + ); + vm.eval( + r#" + globalThis.face=new FontFace('Relative','url(font.woff2)'); + document.querySelector('base').href='/after/'; + globalThis.loaded=false;face.load().then(value=>loaded=value===face); + "#, + ) + .unwrap(); + let request = tokio::time::timeout(Duration::from_secs(2), requests.recv()) + .await + .unwrap() + .unwrap(); + gates.remove("font.woff2").unwrap().send(()).unwrap(); + server.await.unwrap(); + assert_eq!(request, "/before/font.woff2"); + assert!( + tokio::time::timeout( + Duration::from_secs(2), + completions.wait_for_arrival_without_timeout() + ) + .await + .unwrap() + ); + let completion = completions.pop_next_async_subresource_event().unwrap(); + let activity = vm + .complete_async_subresource_fetch_event_body(completion) + .unwrap(); + vm.finish_async_subresource_body_checkpoint_for_test(activity) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify([face.status,loaded])").unwrap(), + r#"["loaded",true]"# + ); +}