mirror of
https://github.com/lexmount/moli.git
synced 2026-10-02 08:00:47 +00:00
fix(forms): keep popup submissions in their browsing context
Share the native document browsing-context check with visibility getters. Route popup self, parent and top submissions through popup navigation, preserving POST requests and fetching again for same-URL POSTs. Cover real GET/POST requests, borrowed submit methods, repeated POST, navigation cancellation and closed popup documents. Validation: cargo fmt --all; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo nextest run --no-fail-fast (18945 passed, 16 skipped).
This commit is contained in:
@@ -150,11 +150,14 @@ pub(crate) use location_navigation::{
|
||||
pub(crate) use navigation_cancellation::{
|
||||
NavigationCancellationReason, inform_about_canceled_navigation_for_window,
|
||||
};
|
||||
pub(crate) use navigation_events::dispatch_cross_document_navigation_navigate_event_for_window_with_form_data;
|
||||
pub(crate) use navigation_events::{
|
||||
construct_original_hash_change_event, dispatch_beforeunload_for_runtime_owner,
|
||||
dispatch_pagehide_for_runtime_owner, dispatch_unload_for_runtime_owner,
|
||||
};
|
||||
pub(crate) use navigation_events::{
|
||||
dispatch_cross_document_navigation_navigate_event_for_window_with_form_data,
|
||||
dispatch_cross_document_navigation_navigate_event_for_window_with_type_and_form_data,
|
||||
};
|
||||
pub(crate) use navigation_history_pruning::{
|
||||
NavigationHistoryPrunePlan, apply_navigation_history_prune_plan,
|
||||
finalize_navigation_history_prune, plan_navigation_history_prune,
|
||||
|
||||
@@ -2,6 +2,18 @@ use super::workers::WorkerExecutionState;
|
||||
use super::*;
|
||||
|
||||
impl JsContextHost {
|
||||
pub(crate) fn document_has_browsing_context(&self, document: DomHandle) -> bool {
|
||||
// A detached iframe compatibility window can be a document's defaultView
|
||||
// without registering a browsing context. Check the native association.
|
||||
self.dom_host().document_handle() == document
|
||||
|| self
|
||||
.child_browsing_context_host_for_document_handle(document)
|
||||
.is_some()
|
||||
|| self
|
||||
.lightweight_popup_id_for_document_handle(document)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn document_activity(&self) -> moli_page_types::DocumentActivity {
|
||||
self.document_activity
|
||||
}
|
||||
|
||||
@@ -955,7 +955,7 @@ impl JsContextHost {
|
||||
} else {
|
||||
self.start_lightweight_popup_document_load(
|
||||
navigation_task,
|
||||
initial_url.clone(),
|
||||
Request::get_with_url(initial_url.clone()),
|
||||
about_blank_url(),
|
||||
initial_document_state,
|
||||
)
|
||||
@@ -1064,7 +1064,7 @@ impl JsContextHost {
|
||||
} else {
|
||||
self.start_lightweight_popup_document_load(
|
||||
navigation_task,
|
||||
target_url,
|
||||
Request::get_with_url(target_url),
|
||||
previous_url,
|
||||
navigation_state,
|
||||
)
|
||||
@@ -1471,6 +1471,21 @@ impl JsContextHost {
|
||||
popup_id: u64,
|
||||
target_url: Url,
|
||||
kind: crate::context_bootstrap::LocationNavigationKind,
|
||||
) -> bool {
|
||||
self.navigate_lightweight_popup_window_with_request(
|
||||
scope,
|
||||
popup_id,
|
||||
Request::get_with_url(target_url),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn navigate_lightweight_popup_window_with_request(
|
||||
&mut self,
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
popup_id: u64,
|
||||
request: Request,
|
||||
kind: crate::context_bootstrap::LocationNavigationKind,
|
||||
) -> bool {
|
||||
if !self.lightweight_popup_is_open(popup_id) {
|
||||
return false;
|
||||
@@ -1480,10 +1495,13 @@ impl JsContextHost {
|
||||
};
|
||||
let current_url = lightweight_popup_location_href(scope, window)
|
||||
.or_else(|| self.lightweight_popup_location_url(popup_id));
|
||||
if !matches!(
|
||||
kind,
|
||||
crate::context_bootstrap::LocationNavigationKind::Reload
|
||||
) && urls_refer_to_same_document_except_fragment(current_url.as_ref(), &target_url)
|
||||
let target_url = request.url.clone();
|
||||
if request.method == "GET"
|
||||
&& !matches!(
|
||||
kind,
|
||||
crate::context_bootstrap::LocationNavigationKind::Reload
|
||||
)
|
||||
&& urls_refer_to_same_document_except_fragment(current_url.as_ref(), &target_url)
|
||||
{
|
||||
let previous_url = current_url.clone();
|
||||
let base_url = self
|
||||
@@ -1512,11 +1530,13 @@ impl JsContextHost {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if !matches!(
|
||||
kind,
|
||||
crate::context_bootstrap::LocationNavigationKind::Reload
|
||||
) && let Some(previous_url) =
|
||||
self.pending_lightweight_popup_same_document_previous_url(popup_id, &target_url)
|
||||
if request.method == "GET"
|
||||
&& !matches!(
|
||||
kind,
|
||||
crate::context_bootstrap::LocationNavigationKind::Reload
|
||||
)
|
||||
&& let Some(previous_url) =
|
||||
self.pending_lightweight_popup_same_document_previous_url(popup_id, &target_url)
|
||||
{
|
||||
let base_url = self
|
||||
.lightweight_popup_base_url(scope, popup_id)
|
||||
@@ -1626,7 +1646,7 @@ impl JsContextHost {
|
||||
if self
|
||||
.start_lightweight_popup_document_load(
|
||||
navigation_task,
|
||||
target_url,
|
||||
request,
|
||||
previous_url,
|
||||
navigation_state,
|
||||
)
|
||||
@@ -1710,7 +1730,7 @@ impl JsContextHost {
|
||||
}
|
||||
if let Some(load_id) = self.start_lightweight_popup_document_load(
|
||||
navigation_task,
|
||||
target_url,
|
||||
Request::get_with_url(target_url),
|
||||
previous_url,
|
||||
navigation_state,
|
||||
) {
|
||||
@@ -2184,7 +2204,7 @@ impl JsContextHost {
|
||||
fn start_lightweight_popup_document_load(
|
||||
&mut self,
|
||||
task: LightweightPopupNavigationTaskToken,
|
||||
target_url: Url,
|
||||
request: Request,
|
||||
previous_url: Url,
|
||||
document_state: LightweightPopupDocumentState,
|
||||
) -> Option<u64> {
|
||||
@@ -2196,7 +2216,10 @@ impl JsContextHost {
|
||||
self.next_lightweight_popup_document_load_id =
|
||||
self.next_lightweight_popup_document_load_id.wrapping_add(1);
|
||||
let target = LightweightPopupDocumentFetchTarget::new(load_id, task);
|
||||
let local_snapshot = self.materialize_local_child_snapshot_for_url(&target_url);
|
||||
let target_url = request.url.clone();
|
||||
let local_snapshot = (request.method == "GET")
|
||||
.then(|| self.materialize_local_child_snapshot_for_url(&target_url))
|
||||
.flatten();
|
||||
let (resource_loader, request_origin) = if local_snapshot.is_none() {
|
||||
let source_owner = self.current_lightweight_popup_document_owner(popup_id)?;
|
||||
let initiating_loader = self.document_resource_loader_for_window_owner(
|
||||
@@ -2267,7 +2290,7 @@ impl JsContextHost {
|
||||
let result = async {
|
||||
let response = task_resource_loader
|
||||
.fetch(
|
||||
Request::get_with_url(target_url.clone())
|
||||
request
|
||||
.with_request_origin(request_origin)
|
||||
.with_page_network_policy()
|
||||
.with_top_level_navigation_cookie_context(),
|
||||
|
||||
@@ -1063,18 +1063,6 @@ fn current_script_belongs_to_document(
|
||||
.is_some_and(|owner_document| owner_document == document_handle)
|
||||
}
|
||||
|
||||
fn document_has_browsing_context(runtime: &JsContextHost, handle: DomHandle) -> bool {
|
||||
// Detached iframe compatibility windows can become a document's defaultView
|
||||
// without registering a browsing context. They must not make it visible.
|
||||
runtime.dom_host().document_handle() == handle
|
||||
|| runtime
|
||||
.child_browsing_context_host_for_document_handle(handle)
|
||||
.is_some()
|
||||
|| runtime
|
||||
.lightweight_popup_id_for_document_handle(handle)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn document_hidden_getter_function<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
@@ -1086,7 +1074,7 @@ fn document_hidden_getter_function<'s>(
|
||||
return;
|
||||
};
|
||||
let visible = unsafe { &*runtime_ptr }.document_activity().visible
|
||||
&& document_has_browsing_context(unsafe { &*runtime_ptr }, handle);
|
||||
&& unsafe { &*runtime_ptr }.document_has_browsing_context(handle);
|
||||
rv.set_bool(!visible);
|
||||
}
|
||||
|
||||
@@ -1101,7 +1089,7 @@ fn document_visibility_state_getter_function<'s>(
|
||||
return;
|
||||
};
|
||||
let state = if unsafe { &*runtime_ptr }.document_activity().visible
|
||||
&& document_has_browsing_context(unsafe { &*runtime_ptr }, handle)
|
||||
&& unsafe { &*runtime_ptr }.document_has_browsing_context(handle)
|
||||
{
|
||||
"visible"
|
||||
} else {
|
||||
|
||||
@@ -573,15 +573,14 @@ pub(in crate::native_bridge) fn submit_form_default_action(
|
||||
};
|
||||
// A parsed/detached Document has no browsing context to navigate. Resolve
|
||||
// that from the native document, independently of the form's wrapper kind.
|
||||
if source_document.is_none_or(|document| {
|
||||
let runtime = unsafe { &*runtime_ptr };
|
||||
document != runtime.document_handle()
|
||||
&& runtime
|
||||
.child_browsing_context_handle_by_document_handle(scope, document)
|
||||
.is_none()
|
||||
}) {
|
||||
if source_document
|
||||
.is_none_or(|document| !unsafe { &*runtime_ptr }.document_has_browsing_context(document))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let source_popup = source_document.and_then(|document| {
|
||||
unsafe { &*runtime_ptr }.lightweight_popup_id_for_document_handle(document)
|
||||
});
|
||||
let Some(request) =
|
||||
build_form_submission_request(scope, runtime_ptr, form_handle, submitter, action)
|
||||
else {
|
||||
@@ -591,6 +590,29 @@ pub(in crate::native_bridge) fn submit_form_default_action(
|
||||
let special_target = target_name
|
||||
.as_deref()
|
||||
.and_then(SpecialBrowsingContextTarget::parse);
|
||||
// A popup is its own top-level context: _parent and _top also refer to it,
|
||||
// even when the submit method was borrowed from its opener's realm.
|
||||
if let Some(popup_id) = source_popup
|
||||
&& (target_name.is_none()
|
||||
|| matches!(
|
||||
special_target,
|
||||
Some(
|
||||
SpecialBrowsingContextTarget::Current
|
||||
| SpecialBrowsingContextTarget::Parent
|
||||
| SpecialBrowsingContextTarget::Top
|
||||
)
|
||||
))
|
||||
{
|
||||
return submit_form_to_popup_browsing_context(
|
||||
scope,
|
||||
runtime_ptr,
|
||||
form_handle,
|
||||
submitter,
|
||||
popup_id,
|
||||
request,
|
||||
user_initiated,
|
||||
);
|
||||
}
|
||||
match target_name.as_deref() {
|
||||
Some(target_name) if special_target.is_none() => {
|
||||
let target_handle = named_iframe_target_handle_for_navigation(
|
||||
@@ -774,6 +796,87 @@ enum FormSubmissionMethod {
|
||||
},
|
||||
}
|
||||
|
||||
fn submit_form_to_popup_browsing_context(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
runtime_ptr: *mut JsContextHost,
|
||||
form_handle: DomHandle,
|
||||
submitter: Option<DomHandle>,
|
||||
popup_id: u64,
|
||||
submission: FormSubmissionMethod,
|
||||
user_initiated: bool,
|
||||
) -> bool {
|
||||
let source_document = unsafe { &*runtime_ptr }
|
||||
.dom_host()
|
||||
.owner_document_handle(form_handle);
|
||||
if source_document.is_none_or(|document| {
|
||||
unsafe { &*runtime_ptr }.lightweight_popup_id_for_document_handle(document)
|
||||
!= Some(popup_id)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let Some(window) = unsafe { &*runtime_ptr }.lightweight_popup_window(scope, popup_id) else {
|
||||
return false;
|
||||
};
|
||||
let (request, form_data) = match submission {
|
||||
FormSubmissionMethod::Get { resolved_url } => {
|
||||
let Ok(url) = Url::parse(&resolved_url) else {
|
||||
return false;
|
||||
};
|
||||
(moli_fetch::Request::get_with_url(url), None)
|
||||
}
|
||||
FormSubmissionMethod::Post {
|
||||
resolved_url,
|
||||
body,
|
||||
content_type,
|
||||
form_data_entries,
|
||||
} => {
|
||||
let Some(form_data) = form_data_object_from_entries(scope, &form_data_entries) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(request) = moli_fetch::Request::new_bytes(
|
||||
"POST",
|
||||
resolved_url.as_str(),
|
||||
Some(body),
|
||||
vec![("Content-Type".to_owned(), content_type)],
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
(request, Some(form_data))
|
||||
}
|
||||
};
|
||||
let source_element = wrap_handle_object(scope, runtime_ptr, submitter.unwrap_or(form_handle));
|
||||
let navigation_type = if user_initiated { "push" } else { "replace" };
|
||||
if !crate::context_bootstrap::dispatch_cross_document_navigation_navigate_event_for_window_with_type_and_form_data(
|
||||
scope,
|
||||
window,
|
||||
request.url.as_str(),
|
||||
navigation_type,
|
||||
source_element,
|
||||
user_initiated,
|
||||
None,
|
||||
form_data,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
let runtime = unsafe { &mut *runtime_ptr };
|
||||
// A navigate handler can close the popup or replace its document.
|
||||
if source_document.is_none_or(|document| {
|
||||
runtime.lightweight_popup_id_for_document_handle(document) != Some(popup_id)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
runtime.navigate_lightweight_popup_window_with_request(
|
||||
scope,
|
||||
popup_id,
|
||||
request,
|
||||
if user_initiated {
|
||||
crate::context_bootstrap::LocationNavigationKind::Assign
|
||||
} else {
|
||||
crate::context_bootstrap::LocationNavigationKind::Replace
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn dispatch_named_iframe_form_navigation_event(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
runtime_ptr: *mut JsContextHost,
|
||||
|
||||
@@ -117,3 +117,126 @@ fn form_wrappers_share_reset_events_and_keep_inert_submissions_inert() {
|
||||
})()"#).unwrap(), "ok");
|
||||
assert!(vm.take_pending_location_navigation_with_seed().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn form_wrappers_submit_get_to_lightweight_popup() {
|
||||
assert_lightweight_popup_form_submission("get").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn form_wrappers_submit_post_to_lightweight_popup() {
|
||||
assert_lightweight_popup_form_submission("post").await;
|
||||
}
|
||||
|
||||
async fn assert_lightweight_popup_form_submission(method: &str) {
|
||||
let cases = [
|
||||
("", "form.submit()"),
|
||||
("_self", "HTMLFormElement.prototype.submit.call(form)"),
|
||||
("_parent", "form.submit()"),
|
||||
("_top", "form.requestSubmit()"),
|
||||
];
|
||||
// POST to the current URL must still fetch a new document on every submit.
|
||||
let submissions = if method == "post" { 2 } else { 1 };
|
||||
let server = StaticHttpServer::spawn(cases.len() * submissions).await;
|
||||
let loader = static_http_loader([]);
|
||||
let opener_url = server.base_url().join("opener.html").unwrap();
|
||||
let expected_target = if method == "get" {
|
||||
"/submit?field=a+b%2Bc"
|
||||
} else {
|
||||
"/submit?existing=1"
|
||||
};
|
||||
let expected_url = server.base_url().join(expected_target).unwrap();
|
||||
for (target, invocation) in cases {
|
||||
let mut vm = new_page_task_executor_test_vm_with_loader(opener_url.as_str(), &loader);
|
||||
vm.eval("globalThis.popup = open(); globalThis.originalOpenerDocument = document;")
|
||||
.unwrap();
|
||||
for _ in 0..submissions {
|
||||
vm.eval(&format!(
|
||||
r#"(() => {{
|
||||
globalThis.originalPopupDocument = popup.document;
|
||||
const form = popup.document.body.appendChild(popup.document.createElement('form'));
|
||||
form.action = '/submit?existing=1';
|
||||
form.method = '{method}';
|
||||
form.target = '{target}';
|
||||
form.innerHTML = '<input name="field" value="a b+c">';
|
||||
{invocation};
|
||||
}})()"#
|
||||
))
|
||||
.expect("popup form submission should evaluate");
|
||||
assert!(
|
||||
vm.take_pending_location_navigation_with_seed().is_none(),
|
||||
"{method} target={target:?} must not navigate the opener"
|
||||
);
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"String(popup.document !== originalPopupDocument && popup.document.body.textContent === 'child fixture')",
|
||||
"true",
|
||||
"popup form response should replace the popup document",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
vm.eval("popup.location.href").unwrap(),
|
||||
expected_url.as_str()
|
||||
);
|
||||
assert_eq!(vm.eval("location.href").unwrap(), opener_url.as_str());
|
||||
assert_eq!(
|
||||
vm.eval("document === originalOpenerDocument").unwrap(),
|
||||
"true"
|
||||
);
|
||||
}
|
||||
vm.eval("popup.close()").unwrap();
|
||||
}
|
||||
let requests = server.finish().await;
|
||||
assert_eq!(requests.len(), cases.len() * submissions);
|
||||
for request in requests {
|
||||
assert_eq!(request.method, method.to_ascii_uppercase());
|
||||
assert_eq!(request.target, expected_target);
|
||||
if method == "post" {
|
||||
assert_eq!(request.body, b"field=a+b%2Bc");
|
||||
assert_eq!(
|
||||
request.header_value("content-type"),
|
||||
Some("application/x-www-form-urlencoded")
|
||||
);
|
||||
} else {
|
||||
assert!(request.body.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_wrappers_popup_submission_respects_cancellation_and_closed_documents() {
|
||||
let mut vm = new_storage_test_vm("https://form-popup-cancellation.test/");
|
||||
assert_eq!(
|
||||
vm.eval(r#"(() => {
|
||||
globalThis.popup = open();
|
||||
const original = popup.document;
|
||||
const form = original.body.appendChild(original.createElement('form'));
|
||||
form.action = '/submit';
|
||||
form.innerHTML = '<input name="field" value="data">';
|
||||
const events = [];
|
||||
popup.navigation.addEventListener('navigate', event => {
|
||||
events.push([event.sourceElement === form, event.formData && event.formData.get('field')]);
|
||||
event.preventDefault();
|
||||
});
|
||||
for (const method of ['get', 'post']) {
|
||||
form.method = method;
|
||||
HTMLFormElement.prototype.submit.call(form);
|
||||
if (popup.document !== original || popup.location.href !== 'about:blank') throw Error('canceled navigation');
|
||||
}
|
||||
popup.close();
|
||||
for (const method of ['get', 'post']) {
|
||||
form.method = method;
|
||||
HTMLFormElement.prototype.submit.call(form);
|
||||
}
|
||||
return JSON.stringify(events);
|
||||
})()"#).unwrap(),
|
||||
"[[true,null],[true,\"data\"]]"
|
||||
);
|
||||
assert!(vm.take_pending_location_navigation_with_seed().is_none());
|
||||
assert!(
|
||||
!vm._context_host
|
||||
.borrow()
|
||||
.has_pending_lightweight_popup_document_loads()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) struct CapturedHttpRequest {
|
||||
pub(super) host: Option<String>,
|
||||
pub(super) target: String,
|
||||
pub(super) headers: Vec<(String, String)>,
|
||||
pub(super) body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl CapturedHttpRequest {
|
||||
@@ -144,6 +145,7 @@ impl StaticHttpServer {
|
||||
host,
|
||||
target,
|
||||
headers,
|
||||
body: request[header_end..header_end + content_length].to_vec(),
|
||||
};
|
||||
let body = response_body(index, &captured);
|
||||
requests.push(captured);
|
||||
|
||||
Reference in New Issue
Block a user