diff --git a/moli-renderer-v8/src/callback_invocation.rs b/moli-renderer-v8/src/callback_invocation.rs index 8ebbef29e9..3e3bfbc427 100644 --- a/moli-renderer-v8/src/callback_invocation.rs +++ b/moli-renderer-v8/src/callback_invocation.rs @@ -4,7 +4,7 @@ use crate::exception_reporting::{ }; use crate::{ host::WINDOW_EVENT_SLOT, - native_bridge::{JsContextHost, WindowExecutionContextIdentity}, + native_bridge::{JsContextHost, RuntimeObservableContextToken, WindowExecutionContextIdentity}, util::v8str, }; use moli_webidl_callback::{ @@ -208,11 +208,22 @@ impl CallbackInvoker { let value: v8::Local = v8::undefined(scope).into(); return CallbackInvocationOutcome::Returned(v8::Global::new(scope, value)); } - if let (Some(host_ptr), Some(identity)) = - (invocation.host_ptr, invocation.relevant_identity) - && !unsafe { &*host_ptr }.window_execution_context_identity_is_current(identity) - { - return CallbackInvocationOutcome::Retired; + if let Some(host_ptr) = invocation.host_ptr { + let host = unsafe { &*host_ptr }; + let is_retired = match invocation.relevant_identity { + Some(identity) => !host.window_execution_context_identity_is_current(identity), + // A retained Window can keep its V8 global attached after its + // registry entry is removed. Missing authority is not an + // unrestricted callback realm. Worker contexts have no Window + // token and continue using their own delivery authority. + None => invocation + .relevant_context + .get_slot::() + .is_some(), + }; + if is_retired { + return CallbackInvocationOutcome::Retired; + } } let result = with_webidl_callback_contexts( diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs index 33b1f1e886..b59e0a7db7 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs @@ -5,7 +5,7 @@ use crate::document_script_scheduler::FrameDocumentClassicScriptSchedulerWork; use crate::dom::native::Node; use crate::dom_parser::DOM_PARSER_FOREIGN_NODE_SLOT; use crate::native_bridge::{ - OwnerDispatchScope, + OwnerDispatchScope, WindowEnvironmentSettings, document::{detached_native_handle_for_runtime, is_html_document}, node::remove_child_to_current_reaction_queue, throw_dom_exception, @@ -112,6 +112,7 @@ impl JsContextHost { self.clear_child_browsing_context_live_foreign_pairings(scope, document_handle); install_child_document_stream_methods(scope, document, handle); if let Some(window) = window { + let _ = WindowEnvironmentSettings::bind_current_child_document(scope, self, handle); sync_child_document_window_slots( scope, document, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/isolated_world.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/isolated_world.rs index 80c19866fc..409e8f0f42 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/isolated_world.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/isolated_world.rs @@ -80,6 +80,10 @@ impl JsContextHost { let owner = self .current_child_document_task_owner(handle) .ok_or_else(|| anyhow::anyhow!("missing child LocalWindow owner"))?; + let frame_id = self + .frame_owner_frame_id_for_child_handle(handle) + .ok_or_else(|| anyhow::anyhow!("missing child frame identity"))? + .0; let execution_context_owner = WindowExecutionContextOwner::Frame(owner.local_window_id); let dispatch_scope = OwnerDispatchScope::Child(handle); if let Some((_, context)) = @@ -141,7 +145,9 @@ impl JsContextHost { stale.runtime_observable_context_token, ); let stale_context = v8::Local::new(scope, &stale.context); - stale_context.detach_global(); + if self.child_window_proxy_frame_is_current(handle, &stale.frame_id) { + stale_context.detach_global(); + } } let caller_global = scope.get_current_context().global(scope); @@ -179,6 +185,7 @@ impl JsContextHost { pending_contexts.borrow_mut().insert( handle, PrebootstrappedChildDefaultContext { + frame_id, local_window_id: owner.local_window_id, context, bridge_ref, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/realm_state/initialize.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/realm_state/initialize.rs index d271cf6c5d..c80563b0c2 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/realm_state/initialize.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/realm_state/initialize.rs @@ -12,7 +12,7 @@ use crate::{ sync_window_location_history_navigation_runtime_surface, }, native_bridge::{ - JsContextHost, OwnerDispatchScope, WindowExecutionContextOwner, + JsContextHost, OwnerDispatchScope, WindowEnvironmentSettings, WindowExecutionContextOwner, child_window_surface::{ bind_materialized_child_window_indexed_db_factory, initialize_child_window_realm_environment, rebind_child_window_document_environment, @@ -65,6 +65,7 @@ pub(in crate::native_bridge::context_host::child_frame_runtime) fn initialize_ch sync_child_document_window_slots(scope, document, global, true); set_object_slot(scope, global, "document", document.into()); validate_child_window_realm_snapshot(host, &snapshot)?; + bind_document_settings(host, scope, &snapshot)?; Ok(ChildWindowRealmProjection { parent, @@ -125,7 +126,17 @@ pub(in crate::native_bridge::context_host::child_frame_runtime) fn rebind_child_ .ok_or_else(|| anyhow::anyhow!("missing rebound child Document wrapper"))?; sync_child_document_window_slots(scope, document, global, true); set_object_slot(scope, global, "document", document.into()); - validate_child_window_realm_snapshot(host, &snapshot) + validate_child_window_realm_snapshot(host, &snapshot)?; + bind_document_settings(host, scope, &snapshot) +} + +fn bind_document_settings( + host: &JsContextHost, + scope: &mut v8::PinScope<'_, '_>, + snapshot: &super::model::ChildWindowRealmSnapshot, +) -> Result<()> { + WindowEnvironmentSettings::bind_current_child_document(scope, host, snapshot.handle) + .ok_or_else(|| anyhow::anyhow!("missing current child settings Document")) } fn validate_registered_realm( diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs index 996a33d994..b7b965a35c 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs @@ -897,6 +897,18 @@ impl JsContextHost { self.child_window_proxy_records.clear_live_records(handle); } + /// Only navigation within the same browsing context reuses its proxy. + /// A removed/reinserted iframe element can have the same DOM handle while + /// exposing an entirely new browsing context and WindowProxy. + pub(crate) fn child_window_proxy_frame_is_current( + &self, + handle: DomHandle, + frame_id: &str, + ) -> bool { + self.frame_owner_frame_id_for_child_handle(handle) + .is_some_and(|current| current.0 == frame_id) + } + pub(in crate::native_bridge::context_host) fn retain_live_child_window_proxy_records( &mut self, live_handles: &HashSet, diff --git a/moli-renderer-v8/src/native_bridge/context_host/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/mod.rs index 025003655a..39c793e708 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -224,9 +224,10 @@ pub(crate) use selection_records::{ }; use websockets::WebSocketConnectionState; pub(crate) use window_execution_context::{ - DetachedWindowFetchContext, WindowExecutionContextAccessPolicy, WindowExecutionContextBinding, - WindowExecutionContextIdentity, WindowExecutionContextOwner, WindowFetchContext, - WindowOperationReceiver, WindowOperationReceiverCaptureError, WindowTaskTarget, + DetachedWindowFetchContext, WindowEnvironmentSettings, WindowExecutionContextAccessPolicy, + WindowExecutionContextBinding, WindowExecutionContextIdentity, WindowExecutionContextOwner, + WindowFetchContext, WindowOperationReceiver, WindowOperationReceiverCaptureError, + WindowTaskTarget, }; use window_execution_context::{ WindowExecutionContextRealmRecords, WindowExecutionContextRealmRegistration, @@ -236,6 +237,7 @@ use workers::WorkerConnectionState; pub(crate) use workers::WorkerOwnerScope; pub(crate) struct PrebootstrappedChildDefaultContext { + pub(crate) frame_id: String, pub(crate) local_window_id: crate::frame_owner_model::LocalWindowId, pub(crate) context: v8::Global, pub(crate) bridge_ref: JsContextHostBridgeRef, diff --git a/moli-renderer-v8/src/native_bridge/context_host/window_execution_context.rs b/moli-renderer-v8/src/native_bridge/context_host/window_execution_context.rs index e953f3aae5..db36724c1e 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/window_execution_context.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/window_execution_context.rs @@ -1,6 +1,6 @@ //! Registry-backed Window realms and Window-owned asynchronous work. //! -//! The submodules deliberately keep three concepts separate: +//! The submodules keep realm authority, operation targets, and settings separate: //! //! - `registry` is the authority for realm ownership and access policy; //! - `binding` is a stable ScriptState-like reference to one registered realm; @@ -8,6 +8,8 @@ //! WebIDL conversion can run author code; //! - `fetch` couples that realm to the LocalWindow whose request lifetime it //! follows. +//! - `settings` keeps a realm's associated Document available to pure APIs +//! after its execution authority has retired. //! //! In particular, a binding is never rewritten to point at another Window. @@ -15,6 +17,7 @@ mod binding; mod fetch; mod operation_receiver; mod registry; +mod settings; pub(crate) use binding::WindowExecutionContextBinding; pub(crate) use fetch::{DetachedWindowFetchContext, WindowFetchContext, WindowTaskTarget}; @@ -26,3 +29,4 @@ pub(super) use registry::{ WindowExecutionContextRealmRecords, WindowExecutionContextRealmRegistration, WindowExecutionContextScopedRealmRegistration, }; +pub(crate) use settings::WindowEnvironmentSettings; diff --git a/moli-renderer-v8/src/native_bridge/context_host/window_execution_context/settings.rs b/moli-renderer-v8/src/native_bridge/context_host/window_execution_context/settings.rs new file mode 100644 index 0000000000..96be323e83 --- /dev/null +++ b/moli-renderer-v8/src/native_bridge/context_host/window_execution_context/settings.rs @@ -0,0 +1,75 @@ +use super::super::JsContextHost; +use super::super::OwnerDispatchScope; +use super::WindowExecutionContextIdentity; +use crate::document_runtime::DomHandle; +use moli_url::WebOrigin; +use std::rc::Rc; +use url::Url; + +/// Document-backed settings for pure APIs in a retained Window realm. +/// +/// Unlike an execution-context binding, these survive owner retirement. They +/// grant no authority to dispatch tasks or start network requests. The native +/// Document remains in the host's DOM arena after iframe removal; retaining its +/// identity lets subsequent calls observe live base changes without consulting +/// a reused iframe handle. No V8 Global is held here, so this slot does not keep +/// its own Context alive. +pub(crate) struct WindowEnvironmentSettings { + document: DomHandle, + origin: WebOrigin, + execution_identity: WindowExecutionContextIdentity, +} + +impl WindowEnvironmentSettings { + pub(in crate::native_bridge::context_host) fn bind_current_child_document( + scope: &mut v8::PinScope<'_, '_>, + host: &JsContextHost, + child_handle: DomHandle, + ) -> Option<()> { + // A reused element can point to a new Window. Only a currently + // registered realm may change its associated Document; a retained old + // realm keeps its settings even when the same handle becomes live. + let execution_identity = host + .current_runtime_window_execution_context_identity_for_dispatch_scope( + scope, + OwnerDispatchScope::Child(child_handle), + )?; + let document = host.child_browsing_context_document_handle(child_handle)?; + let owner = host.frame_owner_current_child_snapshot(child_handle)?; + let origin = WebOrigin::from_serialized(&owner.settings.origin); + let _previous = scope.get_current_context().set_slot(Rc::new(Self { + document, + origin, + execution_identity, + })); + Some(()) + } + + pub(crate) fn for_current_realm(scope: &mut v8::PinScope<'_, '_>) -> Option> { + scope.get_current_context().get_slot::() + } + + pub(crate) fn api_base_url(&self, host: &JsContextHost) -> Option { + let document = host.dom_host().node(self.document)?.as_document()?; + // Preserve the existing bootstrap view while an initial empty Document + // still stands in for a locally available child snapshot. An explicit + // base on the associated Document always wins. Consult the live route + // only for this exact realm and Document, never a reused element. + if document.base_element_url().is_none() + && host.window_execution_context_identity_is_current(self.execution_identity) + && let OwnerDispatchScope::Child(handle) = self.execution_identity.dispatch_scope() + && host.child_browsing_context_document_handle(handle) == Some(self.document) + && host + .frame_owner_store + .current_child_document_creation_kind(handle) + .is_some_and(|kind| kind.is_initial_empty()) + { + return host.child_browsing_context_base_url(handle); + } + Some(document.base_url().clone()) + } + + pub(crate) fn origin(&self) -> &WebOrigin { + &self.origin + } +} diff --git a/moli-renderer-v8/src/network_host/fetch_surface.rs b/moli-renderer-v8/src/network_host/fetch_surface.rs index cfb67e1ba2..af285a8333 100644 --- a/moli-renderer-v8/src/network_host/fetch_surface.rs +++ b/moli-renderer-v8/src/network_host/fetch_surface.rs @@ -987,10 +987,9 @@ fn readable_stream_locked( pub(in crate::network_host) fn new_abort_signal_for_request<'s>( scope: &mut v8::PinScope<'s, '_>, ) -> Option> { - let global = scope.get_current_context().global(scope); - let ctor = global - .get(scope, v8str(scope, "AbortController").into()) - .and_then(|value| v8::Local::::try_from(value).ok())?; + let ctor = + crate::context_bootstrap::ensure_intrinsic_interface_constructor(scope, "AbortController") + .ok()?; let controller = ctor.new_instance(scope, &[])?; controller.get(scope, v8str(scope, "signal").into()) } @@ -1002,10 +1001,9 @@ pub(in crate::network_host) fn new_abort_signal_for_request_with_source<'s>( let Some(source) = source else { return new_abort_signal_for_request(scope); }; - let global = scope.get_current_context().global(scope); - let abort_signal_ctor = global - .get(scope, v8str(scope, "AbortSignal").into()) - .and_then(|value| v8::Local::::try_from(value).ok())?; + let abort_signal_ctor = + crate::context_bootstrap::ensure_intrinsic_interface_constructor(scope, "AbortSignal") + .ok()?; let any = abort_signal_ctor .get(scope, v8str(scope, "any").into()) .and_then(|value| v8::Local::::try_from(value).ok())?; diff --git a/moli-renderer-v8/src/network_host/request/error.rs b/moli-renderer-v8/src/network_host/request/error.rs index 246fd044a3..023f90ba54 100644 --- a/moli-renderer-v8/src/network_host/request/error.rs +++ b/moli-renderer-v8/src/network_host/request/error.rs @@ -5,6 +5,7 @@ use std::fmt; #[derive(Debug)] pub(crate) enum RequestUrlError { Resolve(ResolveContextUrlError), + AssociatedDocumentUnavailable, OpaqueWorkerBase { input: String }, } @@ -18,6 +19,9 @@ impl fmt::Display for RequestUrlError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Resolve(error) => error.fmt(f), + Self::AssociatedDocumentUnavailable => { + f.write_str("Request settings Document is unavailable") + } Self::OpaqueWorkerBase { input } => write!(f, "Failed to parse URL from {input}"), } } @@ -27,7 +31,7 @@ impl std::error::Error for RequestUrlError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Resolve(error) => Some(error), - Self::OpaqueWorkerBase { .. } => None, + Self::AssociatedDocumentUnavailable | Self::OpaqueWorkerBase { .. } => None, } } } diff --git a/moli-renderer-v8/src/network_host/request/input.rs b/moli-renderer-v8/src/network_host/request/input.rs index de5c188c63..d679dedd8d 100644 --- a/moli-renderer-v8/src/network_host/request/input.rs +++ b/moli-renderer-v8/src/network_host/request/input.rs @@ -1,5 +1,6 @@ use super::super::headers::HeadersGuard; use super::*; +use crate::native_bridge::WindowEnvironmentSettings; use crate::web_api_interfaces; use crate::webidl; use moli_url::WebOrigin; @@ -354,13 +355,20 @@ pub(crate) fn try_resolve_request_constructor_url_for_scope( } if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { let host = unsafe { &*host_ptr }; - let api_base_url = child_handle - .and_then(|handle| host.child_browsing_context_base_url(handle)) - .unwrap_or_else(|| { - let owner = effective_subresource_request_owner(scope, host); - subresource_api_base_url(scope, host, owner) - .unwrap_or_else(|| host.document_url().clone()) - }); + let api_base_url = + if let Some(settings) = WindowEnvironmentSettings::for_current_realm(scope) { + settings + .api_base_url(host) + .ok_or(RequestUrlError::AssociatedDocumentUnavailable)? + } else { + child_handle + .and_then(|handle| host.child_browsing_context_base_url(handle)) + .unwrap_or_else(|| { + let owner = effective_subresource_request_owner(scope, host); + subresource_api_base_url(scope, host, owner) + .unwrap_or_else(|| host.document_url().clone()) + }) + }; resolve_context_url(&api_base_url, input, None) .map(|url| url.to_string()) .map_err(RequestUrlError::from) @@ -396,6 +404,9 @@ pub(super) fn normalize_request_referrer(scope: &mut v8::PinScope<'_, '_>, input } fn current_request_context_origin(scope: &mut v8::PinScope<'_, '_>) -> Option { + if let Some(settings) = WindowEnvironmentSettings::for_current_realm(scope) { + return Some(settings.origin().clone()); + } if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { let host = unsafe { &*host_ptr }; let owner = effective_subresource_request_owner(scope, host); diff --git a/moli-renderer-v8/src/script_vm/execution_contexts.rs b/moli-renderer-v8/src/script_vm/execution_contexts.rs index b4d724d1a1..0a26cfaa37 100644 --- a/moli-renderer-v8/src/script_vm/execution_contexts.rs +++ b/moli-renderer-v8/src/script_vm/execution_contexts.rs @@ -417,12 +417,18 @@ impl ScriptVm { context.runtime_observable_context_token, ); let context_ptr = &context.context as *const v8::Global; + let reuses_window_proxy = self + ._context_host + .borrow() + .child_window_proxy_frame_is_current(child_handle, &context.frame_id); self.renderer_document_isolate .with_entered_renderer_document_isolate(|isolate| { let scope = pin!(v8::HandleScope::new(isolate)); let scope = &mut scope.init(); let context = unsafe { v8::Local::new(scope, &*context_ptr) }; - context.detach_global(); + if reuses_window_proxy { + context.detach_global(); + } Ok(()) })?; // Cancellation can synchronously install a successor realm or @@ -544,11 +550,11 @@ impl ScriptVm { let mut contexts = self.prebootstrapped_child_default_contexts.borrow_mut(); stale_prebootstrapped_handles .into_iter() - .filter_map(|handle| contexts.remove(&handle)) + .filter_map(|handle| contexts.remove(&handle).map(|context| (handle, context))) .collect::>() }; if !stale_prebootstrapped_contexts.is_empty() { - for context in &stale_prebootstrapped_contexts { + for (_, context) in &stale_prebootstrapped_contexts { self.cancel_history_traversals_for_retiring_window( crate::native_bridge::WindowExecutionContextOwner::Frame( context.local_window_id, @@ -557,19 +563,25 @@ impl ScriptVm { } { let mut host = self._context_host.borrow_mut(); - for context in &stale_prebootstrapped_contexts { + for (_, context) in &stale_prebootstrapped_contexts { host.retire_window_execution_contexts_for_context_token( context.runtime_observable_context_token, ); } } + let context_host = self._context_host.clone(); let _ = self .renderer_document_isolate .with_entered_renderer_document_isolate(|isolate| { let scope = pin!(v8::HandleScope::new(isolate)); let scope = &mut scope.init(); - for context in &stale_prebootstrapped_contexts { - v8::Local::new(scope, &context.context).detach_global(); + for (handle, context) in &stale_prebootstrapped_contexts { + if context_host + .borrow() + .child_window_proxy_frame_is_current(*handle, &context.frame_id) + { + v8::Local::new(scope, &context.context).detach_global(); + } } Ok(()) }); @@ -692,37 +704,45 @@ impl ScriptVm { "child default context must retain its document-owned Inspector registration" ); let context_host = self._context_host.clone(); - let detach_result = self + let proxy_cleanup_result = self .renderer_document_isolate .with_entered_renderer_document_isolate(|isolate| { let scope = pin!(v8::HandleScope::new(isolate)); let scope = &mut scope.init(); let local_context = unsafe { v8::Local::new(scope, &*context_ptr) }; - local_context.detach_global(); let host_ptr = (*context_host).as_ptr(); let host = unsafe { &mut *host_ptr }; - if host.child_browsing_context_is_live(context.child_handle) - && !host.preserve_child_window_proxy_between_realms(scope, context.child_handle) - { - anyhow::bail!("failed to park the live child WindowProxy between realms"); + let reuses_window_proxy = host + .child_window_proxy_frame_is_current(context.child_handle, &context.frame_id); + // Navigation within the same frame reuses its WindowProxy. + // Removal (including remove/reinsert of the same element) + // creates a different browsing context. Keep the old proxy + // attached to its retained Window so its constructors remain + // callable after execution authority has been retired. + if reuses_window_proxy { + local_context.detach_global(); + if !host.preserve_child_window_proxy_between_realms(scope, context.child_handle) + { + anyhow::bail!("failed to park the live child WindowProxy between realms"); + } } - Ok(()) + Ok(reuses_window_proxy) }); - if let Err(error) = detach_result { - tracing::warn!( + match proxy_cleanup_result { + Err(error) => tracing::warn!( %error, execution_context_id, child_handle = context.child_handle.index(), owner_realm_id = ?context.owner_realm_id, - "failed to detach retired child WindowProxy global" - ); - } else { - tracing::debug!( + "failed to finalize retired child WindowProxy" + ), + Ok(reuses_window_proxy) => tracing::debug!( execution_context_id, child_handle = context.child_handle.index(), owner_realm_id = ?context.owner_realm_id, - "detached retired child WindowProxy global for identity reuse" - ); + reuses_window_proxy, + "finalized retired child WindowProxy" + ), } } diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/document_navigator.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/document_navigator.rs index 1f21e2fb88..c981b3749d 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/document_navigator.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/document_navigator.rs @@ -600,6 +600,198 @@ fn request_relative_urls_follow_live_srcdoc_iframe_base_urls() { ); } +fn assert_retained_request_uses_child_document(vm: &mut ScriptVm, fallback_base: &str) { + vm.exec( + r#" +globalThis.retainedChildDocument = requestBaseFrame.contentDocument; +globalThis.RetainedChildRequest = requestBaseFrame.contentWindow.Request; +retainedChildDocument.head.innerHTML = ''; +"#, + None, + ) + .expect("child Document and constructors should be retained"); + + let fallback_base_url = Url::parse(fallback_base).unwrap(); + let allowed_referrer = fallback_base_url.join("/allowed-referrer").unwrap(); + let parent_item = fallback_base_url.join("/parent-base/item").unwrap(); + let assert_base = |vm: &mut ScriptVm, base: &str| { + let result = vm + .eval(&format!( + r#" +(() => {{ +const controller = new AbortController(); +controller.abort('retained-reason'); +const request = new RetainedChildRequest('item', {{signal: controller.signal}}); +return JSON.stringify({{ + urls: ['item', '../item?q#f', '?q', '#f'].map(input => new RetainedChildRequest(input).url), + referrer: new RetainedChildRequest('item', {{referrer: 'referrer'}}).referrer, + allowedReferrer: new RetainedChildRequest('item', {{referrer: {allowed_referrer}}}).referrer, + signalAborted: request.signal.aborted, + signalReason: request.signal.reason, + parent: new Request('item').url +}}); +}})() +"#, + allowed_referrer = serde_json::to_string(allowed_referrer.as_str()).unwrap(), + )) + .unwrap_or_else(|error| panic!("retained child Request at base {base}: {error}")); + let base = Url::parse(base).unwrap(); + let relative_referrer = base.join("referrer").unwrap(); + let expected_referrer = if relative_referrer.origin() == allowed_referrer.origin() { + relative_referrer.as_str() + } else { + "about:client" + }; + let expected_urls = + ["item", "../item?q#f", "?q", "#f"].map(|input| base.join(input).unwrap().to_string()); + let actual: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + actual, + serde_json::json!({ + "urls": expected_urls, + "referrer": expected_referrer, + "allowedReferrer": allowed_referrer.as_str(), + "signalAborted": true, + "signalReason": "retained-reason", + "parent": parent_item.as_str(), + }), + "retained Document base {base}" + ); + }; + + assert_base(vm, "https://fixture.test/child/"); + vm.exec("requestBaseFrame.remove();", None) + .expect("iframe should detach"); + assert_base(vm, "https://fixture.test/child/"); + assert!(vm.live_child_default_runtime_realm_inventory().is_empty()); + assert_base(vm, "https://fixture.test/child/"); + + vm.exec( + "retainedChildDocument.querySelector('base').href = 'https://fixture.test/changed/';", + None, + ) + .expect("retained Document base should remain mutable"); + assert_base(vm, "https://fixture.test/changed/"); + vm.exec( + "retainedChildDocument.querySelector('base').remove();", + None, + ) + .expect("retained Document base should be removable"); + assert_base(vm, fallback_base); + + vm.exec( + "requestBaseFrame.removeAttribute('src'); document.body.appendChild(requestBaseFrame);", + None, + ) + .expect("the same iframe element should reattach"); + vm.exec( + "requestBaseFrame.contentDocument.head.innerHTML = '';", + None, + ) + .expect("replacement child Document should get its own base"); + assert_eq!( + vm.eval("new requestBaseFrame.contentWindow.Request('item').url") + .unwrap(), + "https://replacement.test/new/item" + ); + assert_base(vm, fallback_base); +} + +#[test] +fn request_retains_initial_about_blank_document_after_iframe_removal() { + let mut vm = new_storage_test_vm("https://request-base.test/dir/page.html"); + install_request_base_parent_document(&mut vm); + vm.exec("document.body.appendChild(requestBaseFrame);", None) + .unwrap(); + vm.drain_pending_child_frame_work_for_test(); + assert_retained_request_uses_child_document(&mut vm, "https://request-base.test/parent-base/"); +} + +#[test] +fn request_retains_srcdoc_document_after_iframe_removal() { + let mut vm = new_storage_test_vm("https://request-base.test/dir/page.html"); + install_request_base_parent_document(&mut vm); + vm.exec( + "requestBaseFrame.srcdoc = ''; \ + document.body.appendChild(requestBaseFrame);", + None, + ) + .unwrap(); + vm.drain_pending_child_frame_work_for_test(); + assert_retained_request_uses_child_document(&mut vm, "https://request-base.test/parent-base/"); +} + +#[test] +fn request_retains_child_settings_when_iframe_handle_is_reused_before_cleanup() { + let mut vm = new_storage_test_vm("https://request-base.test/dir/page.html"); + install_request_base_parent_document(&mut vm); + vm.exec( + r#" +document.body.appendChild(requestBaseFrame); +requestBaseFrame.contentDocument.head.innerHTML = ''; +globalThis.RetainedChildRequest = requestBaseFrame.contentWindow.Request; +requestBaseFrame.remove(); +document.body.appendChild(requestBaseFrame); +requestBaseFrame.contentDocument.head.innerHTML = ''; +"#, + None, + ) + .unwrap(); + vm.live_child_default_runtime_realm_inventory(); + assert_eq!( + vm.eval("new RetainedChildRequest('item').url + '|' + new requestBaseFrame.contentWindow.Request('item').url") + .unwrap(), + "https://fixture.test/child/item|https://replacement.test/new/item" + ); +} + +#[test] +fn request_retains_child_origin_after_iframe_removal() { + let mut vm = new_storage_test_vm("https://request-base.test/dir/page.html"); + install_request_base_parent_document(&mut vm); + vm.exec( + r#" +document.body.appendChild(requestBaseFrame); +requestBaseFrame.contentDocument.head.innerHTML = ''; +globalThis.RetainedChildRequest = requestBaseFrame.contentWindow.Request; +requestBaseFrame.remove(); +"#, + None, + ) + .unwrap(); + assert!(vm.live_child_default_runtime_realm_inventory().is_empty()); + { + // Give the top fixture different settings without changing its URL. + // Only the constructor is retained; no child Document/Window reference + // or live owner registry is available to supply its original origin. + let mut host = vm._context_host.borrow_mut(); + let loader = host.current_main_document_resource_loader().unwrap(); + let context = loader.fetch_context(); + host.retire_document_resource_loader(context.owner()) + .unwrap(); + host.register_committed_document_resource_loader( + crate::network::context::DocumentFetchContext::new( + context.owner(), + context.document_url().clone(), + context.base_url().clone(), + "https://other-origin.test", + ), + crate::network::context::DocumentResourceAuthoritySource::Inherited(loader), + ); + } + assert_eq!( + vm.eval( + r#"JSON.stringify([ +new RetainedChildRequest('item').url, +new RetainedChildRequest('item', {referrer: 'https://request-base.test/allowed'}).referrer, +new RetainedChildRequest('item', {referrer: 'https://other-origin.test/rejected'}).referrer +])"#, + ) + .unwrap(), + r#"["https://fixture.test/child/item","https://request-base.test/allowed","about:client"]"# + ); +} + #[tokio::test] async fn request_relative_urls_follow_live_http_iframe_base_urls() { let (server_url, server) = spawn_lightweight_popup_response_html_server( @@ -653,5 +845,6 @@ async fn request_relative_urls_follow_live_http_iframe_base_urls() { child_url.as_str(), child_url.as_str(), ); + assert_retained_request_uses_child_document(&mut vm, child_url.as_str()); server.await.expect("iframe response server should finish"); }