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:
ldm0
2026-09-29 15:23:17 +08:00
committed by Donough Liu
parent c2972bb991
commit d732fcd8c0
7 changed files with 292 additions and 38 deletions
+4 -1
View File
@@ -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(),
+2 -14
View File
@@ -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);