fix(renderer): preserve navigation events and target FormData realms

This commit is contained in:
ldm0
2026-10-08 21:01:53 +08:00
committed by Donough Liu
parent e32dacb888
commit 4a615ac638
8 changed files with 280 additions and 69 deletions
+1 -1
View File
@@ -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,
};
@@ -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<v8::Value>)],
) -> Option<v8::Local<'s, v8::Value>> {
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>,
+2 -1
View File
@@ -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,
@@ -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
@@ -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<SpecialBrowsingContextTarget>,
) -> Option<v8::Local<'s, v8::Object>> {
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::<v8::Object>::try_from(value).ok()),
Some(SpecialBrowsingContextTarget::Parent) => source_window
.get(scope, v8str(scope, "parent").into())
.and_then(|value| v8::Local::<v8::Object>::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<SpecialBrowsingContextTarget>,
resolved_url: &str,
) -> Option<v8::Local<'s, v8::Object>> {
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::<v8::Object>::try_from(value).ok())?,
Some(SpecialBrowsingContextTarget::Parent) => source_window
.get(scope, v8str(scope, "parent").into())
.and_then(|value| v8::Local::<v8::Object>::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<v8::Local<'s, v8::Object>>,
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>(
@@ -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,
};
@@ -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);
@@ -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",
"<!doctype html><body></body>",
);
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 = '<body></body>';
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::<serde_json::Value>(&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",
"<!doctype html><body></body>",
);
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 = '<body></body>';
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 = '<body></body>';
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 = '<input name="value" value="b">';
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"] {