From 0ebc155cfde696f04eb520f2f85f61a48342f188 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 17 Sep 2026 15:46:31 +0800 Subject: [PATCH] fix(resource-timing): record XMLHttpRequest entries Record XMLHttpRequest response timing through the existing resource entry path for streaming and buffered responses. Cover the request URL, xmlhttprequest initiator type, native PerformanceResourceTiming identity, and exactly one network request. --- .../context_bootstrap/performance_runtime.rs | 58 +++++++++++++++-- moli-renderer-v8/src/network_host/xhr/send.rs | 9 +++ .../src/script_vm/subresource_fetch.rs | 20 ++++++ .../src/script_vm/tests/dom_xhr/xhr.rs | 65 +++++++++++++++++++ 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs index ab96696c2b..fb4f8712e7 100644 --- a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs @@ -228,12 +228,62 @@ impl ResourcePerformanceEntry { response: &crate::protocol_types::NavigationResponse, ) -> Self { let body_size = response.body_bytes().len() as f64; - let header_size = response - .headers + Self::from_response_parts( + name, + initiator_type, + start_unix_millis, + response.status, + &response.headers, + body_size, + ) + } + + pub(crate) fn from_streaming_network_response( + name: impl Into, + initiator_type: &'static str, + start_unix_millis: Option, + response: &moli_fetch::ResponseHead, + body_size: usize, + ) -> Self { + Self::from_response_parts( + name, + initiator_type, + start_unix_millis, + response.status, + &response.headers, + body_size as f64, + ) + } + + pub(crate) fn from_fetch_response( + name: impl Into, + initiator_type: &'static str, + start_unix_millis: Option, + response: &moli_fetch::Response, + ) -> Self { + Self::from_response_parts( + name, + initiator_type, + start_unix_millis, + response.status, + &response.headers, + response.body_bytes().len() as f64, + ) + } + + fn from_response_parts( + name: impl Into, + initiator_type: &'static str, + start_unix_millis: Option, + response_status: u16, + response_headers: &[(String, String)], + body_size: f64, + ) -> Self { + let header_size = response_headers .iter() .map(|(name, value)| name.len() + value.len() + 4) .sum::() as f64; - let content_type = moli_web_mime::response_content_type(&response.headers) + let content_type = moli_web_mime::response_content_type(response_headers) .and_then(|value| moli_web_mime::mime_essence(&value)) .unwrap_or_default(); Self { @@ -246,7 +296,7 @@ impl ResourcePerformanceEntry { // The fetch lifecycle does not yet retain render-blocking metadata. // Avoid claiming that a resource blocked rendering until it does. render_blocking_status: "non-blocking".to_owned(), - response_status: f64::from(response.status), + response_status: f64::from(response_status), content_type, } } diff --git a/moli-renderer-v8/src/network_host/xhr/send.rs b/moli-renderer-v8/src/network_host/xhr/send.rs index 1166fd6bcd..f03356cb93 100644 --- a/moli-renderer-v8/src/network_host/xhr/send.rs +++ b/moli-renderer-v8/src/network_host/xhr/send.rs @@ -428,6 +428,15 @@ fn send_synchronous_network_xhr( match result { Ok(response) => { + crate::context_bootstrap::record_resource_performance_entry( + scope, + crate::context_bootstrap::ResourcePerformanceEntry::from_fetch_response( + prepared.resolved_url.as_str(), + "xmlhttprequest", + None, + &response, + ), + ); let observable_headers = crate::network_host::filter_cors_exposed_response_headers( &prepared.request_origin, &response.head(), diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch.rs b/moli-renderer-v8/src/script_vm/subresource_fetch.rs index 0364570a94..ee93862e51 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch.rs @@ -3740,6 +3740,15 @@ impl ScriptVm { resolver.resolve(scope, response_obj.into()); } PendingSubresourceContinuation::Xhr(xhr) => { + crate::context_bootstrap::record_resource_performance_entry( + scope, + crate::context_bootstrap::ResourcePerformanceEntry::from_network_response( + pending.info.url.as_str(), + "xmlhttprequest", + None, + &observable_response, + ), + ); let xhr = v8::Local::new(scope, &xhr); let (head, body) = observable_response.into_body(); crate::network_host::apply_xhr_response_body_source_with_status_text( @@ -5542,6 +5551,7 @@ impl ScriptVm { let finish_body_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now); let response_body = streaming.body_writer.finish(); + let response_body_size = response_body.len(); trace_async_subresource_stage( "async_subresource_streaming_body_finished", trace_fields, @@ -5697,6 +5707,16 @@ impl ScriptVm { { let xhr_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now); + crate::context_bootstrap::record_resource_performance_entry( + scope, + crate::context_bootstrap::ResourcePerformanceEntry::from_streaming_network_response( + streaming.pending.info.url.as_str(), + "xmlhttprequest", + None, + &streaming.head, + response_body_size, + ), + ); let xhr = v8::Local::new(scope, &xhr); let mut observable_head = streaming.head; if !streaming diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs index 7d6d09805a..b9e515061e 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs @@ -83,6 +83,71 @@ async fn same_origin_window_fetch_and_xhr_post_send_origin_on_wire() { } } +#[tokio::test(flavor = "current_thread")] +async fn asynchronous_window_xhr_records_resource_timing_with_xmlhttprequest_initiator() { + let server = StaticHttpServer::spawn(1).await; + let base_url = server.base_url(); + let loader = static_http_loader(std::iter::empty::()); + let mut vm = new_page_task_executor_test_vm_with_loader( + base_url + .join("page.html") + .expect("page fixture URL") + .as_str(), + &loader, + ); + + vm.eval( + r#" +(() => { + globalThis.__xhrResourceTimingProbe = "pending"; + const xhr = new XMLHttpRequest(); + xhr.open("GET", "xhr-resource"); + xhr.onload = () => { globalThis.__xhrResourceTimingProbe = "done"; }; + xhr.onerror = () => { globalThis.__xhrResourceTimingProbe = "error"; }; + xhr.send(); + return "started"; +})() +"#, + ) + .expect("XHR resource timing probe should evaluate"); + + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(globalThis.__xhrResourceTimingProbe)", + "done", + "XHR resource timing probe", + ) + .await; + + let entry = vm + .eval( + r#" +(() => { + const url = new URL("xhr-resource", location.href).href; + const entries = performance.getEntriesByName(url, "resource"); + return JSON.stringify(entries.map(entry => ({ + name: entry.name, + initiatorType: entry.initiatorType, + isResourceTiming: entry instanceof PerformanceResourceTiming + }))); +})() +"#, + ) + .expect("XHR resource timing entry should be readable"); + assert_eq!( + entry, + format!( + r#"[{{"name":"{}","initiatorType":"xmlhttprequest","isResourceTiming":true}}]"#, + base_url.join("xhr-resource").expect("XHR resource URL") + ) + ); + + let requests = server.finish().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].target, "/xhr-resource"); +} + fn pending_fetch_continuation<'s>( scope: &mut v8::PinScope<'s, '_>, resolver: v8::Local<'s, v8::PromiseResolver>,