mirror of
https://github.com/lexmount/moli.git
synced 2026-10-19 00:01:26 +00:00
fix(renderer): preserve navigation events and target FormData realms
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
Reference in New Issue
Block a user