From 4a615ac6380968167b6ff86d22cc7321650fd8ea Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 7 Oct 2026 16:00:40 +0800 Subject: [PATCH] fix(renderer): preserve navigation events and target FormData realms --- moli-renderer-v8/src/context_bootstrap.rs | 2 +- .../context_bootstrap/form_data_runtime.rs | 16 ++ moli-renderer-v8/src/lib.rs | 3 +- .../element/activation/default_action.rs | 19 +- .../element/activation/targets.rs | 98 +++++---- .../src/native_bridge/element/forms.rs | 2 +- .../native_bridge/element/forms/submission.rs | 16 +- .../navigation/element_navigation.rs | 193 +++++++++++++++++- 8 files changed, 280 insertions(+), 69 deletions(-) diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index c9c656e27d..bf7ad5a2fa 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -209,7 +209,7 @@ mod window_runtime; pub(crate) use form_data_runtime::{ construct_form_data_entries_for_form, form_data_entries_multipart_body_with_prefix, - form_data_entries_to_string_pairs, form_data_object_from_entries, + form_data_entries_to_string_pairs, form_data_object_from_entries_for_window, form_data_object_from_multipart_bytes, form_data_object_from_urlencoded_bytes, snapshot_form_data_value, }; diff --git a/moli-renderer-v8/src/context_bootstrap/form_data_runtime.rs b/moli-renderer-v8/src/context_bootstrap/form_data_runtime.rs index 31ec98e818..add51da132 100644 --- a/moli-renderer-v8/src/context_bootstrap/form_data_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/form_data_runtime.rs @@ -40,6 +40,22 @@ pub(crate) fn form_data_object_from_entries<'s>( Some(form_data.into()) } +pub(crate) fn form_data_object_from_entries_for_window<'s>( + scope: &mut v8::PinScope<'s, '_>, + window: v8::Local<'s, v8::Object>, + entries: &[(String, v8::Global)], +) -> Option> { + let context = super::navigation_window::window_navigation_for_holder(scope, window) + .and_then(|navigation| navigation.get_creation_context(scope)) + .or_else(|| window.get_creation_context(scope))?; + let scope = &mut v8::ContextScope::new(scope, context); + // Navigation FormData belongs to the target Navigation's relevant realm. + // Use its intrinsic constructor even if page code replaced global.FormData. + let form_data = new_world_wrapper(scope)?; + storage::set_form_data_entries(scope, form_data, entries); + Some(form_data.into()) +} + pub(crate) fn snapshot_form_data_value<'s>( scope: &mut v8::PinScope<'s, '_>, value: v8::Local<'s, v8::Value>, diff --git a/moli-renderer-v8/src/lib.rs b/moli-renderer-v8/src/lib.rs index eefee8a7a5..1f07d5e32a 100644 --- a/moli-renderer-v8/src/lib.rs +++ b/moli-renderer-v8/src/lib.rs @@ -209,7 +209,8 @@ pub use context_bootstrap::{ }; pub(crate) use context_bootstrap::{ construct_form_data_entries_for_form, form_data_entries_multipart_body_with_prefix, - form_data_entries_to_string_pairs, form_data_object_from_entries, snapshot_form_data_value, + form_data_entries_to_string_pairs, form_data_object_from_entries_for_window, + snapshot_form_data_value, }; pub use document_cookie_owner::{ DocumentCookieBackendConnectionState, DocumentCookieBrowserContextSnapshot, diff --git a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs index 6b87a047d0..15058b4688 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs @@ -1964,24 +1964,6 @@ fn anchor_click_default_action( } return None; } - if target_name.is_none() || special_target == Some(SpecialBrowsingContextTarget::Current) { - let window = navigation_owner_window_for_handle(scope, runtime_ptr, handle)?; - let source_element = node_wrapper_from_handle(scope, handle); - let can_intercept = url::Url::parse(&resolved) - .is_ok_and(|url| moli_url::same_origin(runtime.document_url(), &url)); - if !crate::context_bootstrap::dispatch_top_level_navigation_event_with_source_element( - scope, - window, - &resolved, - "push", - source_element, - can_intercept, - user_initiated, - None, - ) { - return None; - } - } let source_element = node_wrapper_from_handle(scope, handle); if click_listener_changed_named_child_navigation( scope, @@ -2000,6 +1982,7 @@ fn anchor_click_default_action( target_name.as_deref(), &resolved, source_element, + user_initiated, popup_disposition, ); None diff --git a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs index f0f539c560..fd068f4e60 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs @@ -1,7 +1,8 @@ use crate::{ RendererPendingPopupActivation, RendererPendingWindowOpenEvent, RendererPopupDisposition, context_bootstrap::{ - dispatch_cross_document_navigation_navigate_event_for_window, runtime_window_dispatch_scope, + dispatch_cross_document_navigation_navigate_event_for_window, + dispatch_top_level_navigation_event_with_source_element, runtime_window_dispatch_scope, }, document_runtime::{DocumentPolicyContainer, DomHandle}, native_bridge::context_host::ChildBrowsingContextNavigationRequest, @@ -481,31 +482,32 @@ fn browsing_context_dispatch_scope_for_node( .map(crate::native_bridge::OwnerDispatchScope::Child) } +fn special_target_window_from_window<'s>( + scope: &mut v8::PinScope<'s, '_>, + source_window: v8::Local<'s, v8::Object>, + target: Option, +) -> Option> { + match target { + None | Some(SpecialBrowsingContextTarget::Current) => Some(source_window), + Some(SpecialBrowsingContextTarget::Top) => source_window + .get(scope, v8str(scope, "top").into()) + .and_then(|value| v8::Local::::try_from(value).ok()), + Some(SpecialBrowsingContextTarget::Parent) => source_window + .get(scope, v8str(scope, "parent").into()) + .and_then(|value| v8::Local::::try_from(value).ok()), + Some(SpecialBrowsingContextTarget::Blank) => None, + } +} + fn navigate_special_target_from_window<'s>( scope: &mut v8::PinScope<'s, '_>, - runtime_ptr: *mut JsContextHost, source_window: v8::Local<'s, v8::Object>, target: Option, resolved_url: &str, ) -> Option> { - let target_window = match target { - None | Some(SpecialBrowsingContextTarget::Current) => source_window, - Some(SpecialBrowsingContextTarget::Top) => source_window - .get(scope, v8str(scope, "top").into()) - .and_then(|value| v8::Local::::try_from(value).ok())?, - Some(SpecialBrowsingContextTarget::Parent) => source_window - .get(scope, v8str(scope, "parent").into()) - .and_then(|value| v8::Local::::try_from(value).ok())?, - Some(SpecialBrowsingContextTarget::Blank) => return None, - }; + let target_window = special_target_window_from_window(scope, source_window, target)?; let navigated = if resolved_url.is_empty() { true - } else if matches!( - runtime_window_dispatch_scope(scope, target_window), - Some(crate::native_bridge::OwnerDispatchScope::Top) - ) { - // A borrowed click/submit method can execute in a child Window realm. - queue_top_level_location_navigation(scope, runtime_ptr, resolved_url) } else { navigate_target_window_location(scope, target_window, resolved_url) }; @@ -526,13 +528,7 @@ pub(crate) fn navigate_existing_browsing_context_target<'s>( let dispatch_scope = unsafe { &*runtime_ptr }.entered_owner_dispatch_scope(scope); let source_window = browsing_context_window_for_dispatch_scope(scope, runtime_ptr, dispatch_scope)?; - navigate_special_target_from_window( - scope, - runtime_ptr, - source_window, - Some(target), - resolved_url, - ) + navigate_special_target_from_window(scope, source_window, Some(target), resolved_url) } pub(super) fn navigate_hyperlink_source_browsing_context( @@ -558,7 +554,6 @@ pub(super) fn navigate_hyperlink_source_browsing_context( }; navigate_special_target_from_window( scope, - runtime_ptr, source_window, Some(SpecialBrowsingContextTarget::Current), resolved_url, @@ -599,14 +594,8 @@ pub(crate) fn navigate_target_browsing_context<'s>( else { return false; }; - navigate_special_target_from_window( - scope, - runtime_ptr, - source_window, - None, - resolved_url, - ) - .is_some() + navigate_special_target_from_window(scope, source_window, None, resolved_url) + .is_some() } }; } @@ -644,6 +633,7 @@ pub(in crate::native_bridge) fn navigate_hyperlink_target_browsing_context<'s>( target_name: Option<&str>, resolved_url: &str, source_element: Option>, + user_initiated: bool, popup_disposition: RendererPopupDisposition, ) -> bool { if !hyperlink_javascript_url_allowed_by_csp(scope, runtime_ptr, source_handle, resolved_url) { @@ -693,14 +683,38 @@ pub(in crate::native_bridge) fn navigate_hyperlink_target_browsing_context<'s>( else { return false; }; - navigate_special_target_from_window( - scope, - runtime_ptr, - source_window, - special_target, - resolved_url, - ) - .is_some() + let Some(target_window) = + special_target_window_from_window(scope, source_window, special_target) + else { + return false; + }; + if resolved_url.is_empty() { + return true; + } + if matches!( + runtime_window_dispatch_scope(scope, target_window), + Some(crate::native_bridge::OwnerDispatchScope::Top) + ) { + let runtime = unsafe { &*runtime_ptr }; + let can_intercept = url::Url::parse(resolved_url) + .is_ok_and(|url| moli_url::same_origin(runtime.document_url(), &url)); + // Every top-target hyperlink dispatches once after resolving its owner. + // Cancellation or interception must prevent the navigation from queuing. + if !dispatch_top_level_navigation_event_with_source_element( + scope, + target_window, + resolved_url, + "push", + source_element, + can_intercept, + user_initiated, + None, + ) { + return true; + } + return queue_top_level_location_navigation(scope, runtime_ptr, resolved_url); + } + navigate_target_window_location(scope, target_window, resolved_url) } pub(crate) fn navigate_named_iframe_target<'s>( diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index 7ddd9aee44..398cfc5e51 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -6,7 +6,7 @@ use crate::{ construct_form_data_entries_for_form, custom_elements, document_runtime::DomHandle, form_data_entries_multipart_body_with_prefix, form_data_entries_to_string_pairs, - form_data_object_from_entries, + form_data_object_from_entries_for_window, util::{throw_type_error, v8_string}, webidl, }; diff --git a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs index a53dca966c..c25a26201f 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs @@ -876,7 +876,9 @@ fn submit_form_to_popup_browsing_context( content_type, form_data_entries, } => { - let Some(form_data) = form_data_object_from_entries(scope, &form_data_entries) else { + let Some(form_data) = + form_data_object_from_entries_for_window(scope, window, &form_data_entries) + else { return false; }; let Ok(request) = moli_fetch::Request::new_bytes( @@ -952,7 +954,9 @@ fn dispatch_named_iframe_form_navigation_event( else { return true; }; - let Some(form_data) = form_data_object_from_entries(scope, form_data_entries) else { + let Some(form_data) = + form_data_object_from_entries_for_window(scope, window, form_data_entries) + else { return true; }; let source_handle = submitter.unwrap_or(form_handle); @@ -986,7 +990,9 @@ fn submit_post_form_to_top_level_browsing_context( ) else { return false; }; - let Some(form_data) = form_data_object_from_entries(scope, form_data_entries) else { + let Some(form_data) = + form_data_object_from_entries_for_window(scope, window, form_data_entries) + else { return false; }; let source_handle = submitter.unwrap_or(form_handle); @@ -1042,7 +1048,9 @@ fn submit_post_form_to_child_browsing_context( if let Some(window) = unsafe { &mut *runtime_ptr } .existing_child_browsing_context_window_wrapper(scope, child_handle) { - let Some(form_data) = form_data_object_from_entries(scope, form_data_entries) else { + let Some(form_data) = + form_data_object_from_entries_for_window(scope, window, form_data_entries) + else { return false; }; let source_handle = submitter.unwrap_or(form_handle); diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/element_navigation.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/element_navigation.rs index a1c49d3fc7..87f1c4952e 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/element_navigation.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/element_navigation.rs @@ -2,7 +2,7 @@ use super::*; #[test] fn anchor_navigation_fires_once_when_click_is_borrowed_from_child_realm() { - for target in ["", "_sElF"] { + for target in ["", "_sElF", "_ToP", "_PARENT"] { for use_child_realm in [true, false] { for cancel_top in [false, true] { let mut vm = new_parsed_test_vm( @@ -77,6 +77,99 @@ fn anchor_navigation_fires_once_when_click_is_borrowed_from_child_realm() { } } +#[test] +fn child_navigation_to_top_fires_once_and_honors_cancel_or_intercept() { + for (depth, target) in [(1, "_top"), (1, "_PARENT"), (2, "_ToP")] { + for api in ["click", "open"] { + for use_child_realm in [true, false] { + for action in ["allow", "cancel", "intercept"] { + let mut vm = new_parsed_test_vm( + "https://anchor-navigation-target.test/source", + "", + ); + let description = format!( + "depth={depth} target={target} api={api} child_realm={use_child_realm} action={action}" + ); + let result = vm + .eval(&format!( + r#" + (() => {{ + const seen = []; + let source = document; + for (let i = 0; i < {depth}; ++i) {{ + const frame = source.createElement('iframe'); + source.body.appendChild(frame); + frame.srcdoc = ''; + source = frame.contentDocument; + frame.contentWindow.navigation.onnavigate = event => {{ + seen.push('child:' + i); + event.preventDefault(); + }}; + }} + const anchor = source.createElement('a'); + anchor.href = 'https://anchor-navigation-target.test/destination'; + anchor.target = {target:?}; + source.body.appendChild(anchor); + navigation.onnavigate = event => {{ + seen.push([ + 'top', + event instanceof NavigateEvent, + event.target === navigation, + event.currentTarget === navigation, + event.sourceElement === ({api:?} === 'click' ? anchor : null), + event.canIntercept, + event.destination.url + ]); + event.signal.onabort = () => seen.push('top-abort'); + if ({action:?} === 'cancel') event.preventDefault(); + if ({action:?} === 'intercept') event.intercept({{ handler() {{}} }}); + }}; + navigation.onnavigateerror = () => seen.push('top-error'); + const realm = {use_child_realm} ? source.defaultView : window; + if ({api:?} === 'click') realm.HTMLElement.prototype.click.call(anchor); + else realm.open.call(source.defaultView, anchor.href, anchor.target); + return JSON.stringify(seen); + }})() + "#, + )) + .expect(&description); + let expected_url = "https://anchor-navigation-target.test/destination"; + let mut expected = vec![serde_json::json!([ + "top", + true, + true, + true, + true, + true, + expected_url + ])]; + if action == "cancel" { + expected.extend([ + serde_json::json!("top-abort"), + serde_json::json!("top-error"), + ]); + } + assert_eq!( + serde_json::from_str::(&result).unwrap(), + serde_json::json!(expected), + "{description}" + ); + let pending = vm.take_pending_location_navigation_with_seed(); + if action == "allow" { + assert_eq!( + pending.expect(&description).url.as_str(), + expected_url, + "{description}" + ); + } else { + assert!(pending.is_none(), "{description}"); + } + } + } + } + } +} + #[test] fn form_navigation_events_use_resolved_top_window_across_realms() { for (depth, target) in [(1, "_top"), (1, "_PARENT"), (2, "_ToP")] { @@ -124,7 +217,10 @@ fn form_navigation_events_use_resolved_top_window_across_realms() { event.cancelable, event.navigationType, event.destination.url, - {method:?} === 'get' ? event.formData === null : event.formData.get('value') === 'b' + {method:?} === 'get' ? event.formData === null : event.formData.get('value') === 'b', + event instanceof NavigateEvent, + {method:?} === 'get' || event.formData instanceof FormData, + {method:?} === 'get' || !(event.formData instanceof source.defaultView.FormData) ]); event.signal.onabort = () => seen.push('top-abort'); if ({cancel_top}) event.preventDefault(); @@ -152,6 +248,9 @@ fn form_navigation_events_use_resolved_top_window_across_realms() { true, "replace", expected_url, + true, + true, + true, true ])]; if cancel_top { @@ -189,6 +288,96 @@ fn form_navigation_events_use_resolved_top_window_across_realms() { } } +#[test] +fn post_form_navigation_uses_target_intrinsic_form_data_constructor() { + for (depth, target) in [(1, "_top"), (1, "_self"), (2, "_parent"), (1, "receiver")] { + for api in ["submit", "requestSubmit"] { + for use_child_realm in [true, false] { + let mut vm = new_parsed_test_vm( + "https://form-navigation-target.test/source", + "", + ); + let description = format!( + "depth={depth} target={target} api={api} child_realm={use_child_realm}" + ); + let result = vm + .eval(&format!( + r#" + (() => {{ + const seen = []; + let source = document; + for (let i = 0; i < {depth}; ++i) {{ + const frame = source.createElement('iframe'); + source.body.appendChild(frame); + frame.srcdoc = ''; + source = frame.contentDocument; + }} + const child = source.defaultView; + let targetWindow = {target:?} === '_self' ? child : child.parent; + if ({target:?} === 'receiver') {{ + const receiver = document.createElement('iframe'); + receiver.name = 'receiver'; + document.body.appendChild(receiver); + receiver.srcdoc = ''; + targetWindow = receiver.contentWindow; + }} + const TargetFormData = targetWindow.FormData; + const ChildFormData = child.FormData; + const form = child.document.createElement('form'); + form.method = 'post'; + form.target = {target:?}; + form.action = 'https://form-navigation-target.test/submitted'; + form.innerHTML = ''; + child.document.body.appendChild(form); + let constructorReads = 0; + form.onformdata = event => {{ + event.formData.append('added', 'from-event'); + Object.defineProperty(targetWindow, 'FormData', {{ + configurable: true, + get() {{ + ++constructorReads; + throw new Error('navigation must use the intrinsic FormData'); + }} + }}); + }}; + targetWindow.navigation.onnavigate = event => {{ + seen.push([ + event instanceof targetWindow.NavigateEvent, + event.formData instanceof TargetFormData, + (event.formData instanceof ChildFormData) === (targetWindow === child), + event.formData.get('value'), + event.formData.get('added') + ]); + if (targetWindow !== window) event.preventDefault(); + }}; + const realm = {use_child_realm} ? child : window; + realm.HTMLFormElement.prototype[{api:?}].call(form); + return JSON.stringify([seen, constructorReads]); + }})() + "#, + )) + .expect(&description); + assert_eq!( + result, r#"[[[true,true,true,"b","from-event"]],0]"#, + "{description}" + ); + let pending = vm.take_pending_location_navigation_with_seed(); + if target == "_top" { + let pending = pending.expect(&description); + assert_eq!(pending.request_method, "POST", "{description}"); + assert_eq!( + pending.request_body.as_deref(), + Some(b"value=b&added=from-event".as_slice()), + "{description}" + ); + } else { + assert!(pending.is_none(), "{description}"); + } + } + } + } +} + #[tokio::test] async fn named_element_navigation_prefers_its_source_frame_over_duplicate_names() { for action in ["anchor", "submit", "requestSubmit"] {