fix(navigation): preserve hyperlink navigate events

Route existing hyperlink targets through native Window identity and the shared
navigation path. Preserve click involvement and sourceElement, suppress
cross-origin source exposure, and honor cancellation and interception in child
frames and named popups.

Retain popup referrer policy and synchronous fragment activation after the
navigate event accepts it. Add regression coverage for trusted and synthetic
clicks, ancestor targets, canceled navigation, interception, and popup policy.
This commit is contained in:
ldm0
2026-11-02 06:02:25 +08:00
parent 43ad12f21c
commit e426ce91da
13 changed files with 449 additions and 205 deletions
@@ -1,6 +1,7 @@
use super::*;
mod child_history_traversal;
mod hyperlink_navigation;
mod popup_network_navigation;
mod response_gate;
mod stale_history_traversal;
@@ -0,0 +1,50 @@
async function setupClickProbe(target, mode, destination, action = 'cancel') {
const root = window;
let owner = root, targetWindow = root, frame;
document.body.style.margin = '0';
const childURL = new URL('?child', location.href).href;
if (target.startsWith('child') || target === 'named-child') {
frame = document.createElement('iframe'); frame.name = 'click-destination';
frame.style.cssText = 'position:absolute;left:0;top:0;border:0;width:300px;height:200px';
frame.src = childURL;
await new Promise(resolve => {frame.onload = resolve; document.body.append(frame)});
targetWindow = frame.contentWindow;
if (target.startsWith('child')) owner = targetWindow;
if (target === 'child-top' || target === 'child-parent') targetWindow = root;
} else if (target === 'named-popup') {
targetWindow = open(childURL, 'click-destination');
await new Promise(resolve => targetWindow.addEventListener('load', resolve, {once:true}));
}
await new Promise(resolve => setTimeout(resolve,0));
const link = owner.document.createElement('a');
link.id = 'link'; link.textContent = 'navigate';
link.style.cssText = 'position:absolute;left:0;top:0;display:block;width:120px;height:80px';
const url = new URL(targetWindow.location.href);
if (destination === 'fragment') url.hash = 'click'; else url.search = '?new-document';
link.href = url.href;
if (target.startsWith('named')) link.target = 'click-destination';
if (target === 'child-top') link.target = '_top';
if (target === 'child-parent') link.target = '_parent';
owner.document.body.append(link);
root.records = []; root.clicks = [];
root.probeTarget = targetWindow; root.probeInitialURL = targetWindow.location.href;
root.probeInitialDocument = targetWindow.document; root.probeDestination = url.href;
root.probeSuccess = false;
targetWindow.navigation.onnavigatesuccess = () => { root.probeSuccess = true; };
targetWindow.navigation.onnavigate = event => {
root.records.push({userInitiated:event.userInitiated, source:event.sourceElement === link,
sourceNull:event.sourceElement === null, sameDocument:event.destination.sameDocument,
type:event.navigationType, hashChange:event.hashChange});
if (action === 'cancel') event.preventDefault();
if (action === 'intercept') event.intercept({handler: () => Promise.resolve()});
};
link.onclick = event => {
root.clicks.push(event.isTrusted);
if (mode === 'location') {event.preventDefault(); targetWindow.location.href = url.href;}
if (mode === 'nested-click' && event.isTrusted) {event.preventDefault(); link.click();}
};
root.probeOwner = owner; root.probeLink = link;
if (mode === 'script') link.click();
if (mode === 'dispatch') link.dispatchEvent(new owner.MouseEvent('click', {bubbles:true,cancelable:true}));
return {x:20,y:20};
}
@@ -0,0 +1,132 @@
use super::*;
const SETUP: &str = include_str!("hyperlink_navigation.js");
async fn activate(
page: &mut SameDocumentPage,
target: &str,
mode: &str,
destination: &str,
action: &str,
) {
page.evaluate(&format!(
"{SETUP}\nsetupClickProbe({target:?}, {mode:?}, {destination:?}, {action:?})"
))
.await;
if matches!(mode, "trusted" | "location" | "nested-click") {
for (kind, buttons) in [("mousePressed", 1), ("mouseReleased", 0)] {
page.command("Input.dispatchMouseEvent", json!({
"type": kind, "x": 20, "y": 20, "button": "left", "buttons": buttons, "clickCount": 1,
})).await;
}
}
page.evaluate("new Promise(resolve => setTimeout(resolve, 0))")
.await;
}
async fn check_click_involvement(target: &str) {
for destination in ["fragment", "document"] {
for mode in ["trusted", "script", "dispatch", "location", "nested-click"] {
let mut page = SameDocumentPage::new().await;
activate(&mut page, target, mode, destination, "cancel").await;
let result = page.evaluate(r#"({records, clicks, unchanged: probeTarget.location.href === probeInitialURL && probeTarget.document === probeInitialDocument})"#).await;
let clicks = match mode {
"trusted" | "location" => json!([true]),
"nested-click" => json!([true, false]),
_ => json!([false]),
};
assert_eq!(
result,
json!({
"records": [{
"userInitiated": mode == "trusted", "source": mode != "location", "sourceNull": mode == "location",
"sameDocument": destination == "fragment", "hashChange": destination == "fragment", "type": "push",
}], "clicks": clicks, "unchanged": true,
}),
"{target}/{destination}/{mode}"
);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn hyperlink_navigation_top_click_involvement() {
check_click_involvement("top").await;
}
#[tokio::test(flavor = "multi_thread")]
async fn hyperlink_navigation_child_click_involvement() {
check_click_involvement("child").await;
}
#[tokio::test(flavor = "multi_thread")]
async fn hyperlink_navigation_named_child_click_involvement() {
check_click_involvement("named-child").await;
}
#[tokio::test(flavor = "multi_thread")]
async fn hyperlink_navigation_named_popup_click_involvement() {
check_click_involvement("named-popup").await;
}
#[tokio::test(flavor = "multi_thread")]
async fn hyperlink_navigation_commits_and_intercepts_existing_targets() {
for target in ["top", "child", "named-child", "named-popup"] {
for (destination, action) in [("fragment", "proceed"), ("document", "intercept")] {
let mut page = SameDocumentPage::new().await;
activate(&mut page, target, "trusted", destination, action).await;
let result = page.evaluate(r#"({
reached: probeTarget.location.href === probeDestination,
retained: probeTarget.document === probeInitialDocument,
success: probeSuccess, events: records.length, userInitiated: records[0]?.userInitiated,
})"#).await;
assert_eq!(
result,
json!({"reached": true, "retained": true, "success": true, "events": 1, "userInitiated": true}),
"{target}/{destination}/{action}"
);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn hyperlink_navigation_child_ancestor_click_involvement() {
for target in ["child-top", "child-parent"] {
check_click_involvement(target).await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn hyperlink_navigation_named_popup_preserves_referrer_policy() {
for policy in ["no-referrer", "origin"] {
let route = axum::Router::new().route("/referrer.html", axum::routing::get(|headers: axum::http::HeaderMap| async move {
let referer = headers.get("referer").and_then(|value| value.to_str().ok()).unwrap_or_default();
axum::response::Html(format!(r#"<!doctype html><script>opener.postMessage({{type:'referrer-report', referrer:document.referrer, header:{}}}, '*')</script>"#, serde_json::to_string(referer).unwrap()))
}));
let mut page = SameDocumentPage::with_routes(route).await;
let result = page.evaluate(&format!(r#"(async () => {{
const popup = open('?popup', 'referrer-target');
await new Promise(resolve => popup.addEventListener('load', resolve, {{once:true}}));
const link = document.createElement('a');
link.href = '/referrer.html'; link.target = 'referrer-target'; link.referrerPolicy = {policy:?};
document.body.append(link);
const events = [];
popup.navigation.onnavigate = event => events.push(event.sourceElement === link);
const report = new Promise(resolve => addEventListener('message', event => {{
if (event.data?.type === 'referrer-report') resolve(event.data);
}}));
link.click();
return {{report:await report, events}};
}})()"#)).await;
let expected = if policy == "no-referrer" {
String::new()
} else {
format!("{}/", page.base_url.trim_end_matches("/history.html"))
};
assert_eq!(result["report"]["referrer"], expected, "{policy}: {result}");
assert_eq!(result["events"], json!([true]), "{policy}: {result}");
if policy == "no-referrer" {
assert_eq!(result["report"]["header"], "", "{result}");
}
}
}
+2 -3
View File
@@ -196,9 +196,8 @@ pub(crate) use css_fontface_runtime::{
pub(crate) use form_navigation::FormNavigationHistory;
pub(crate) use history_mutation::update_history_for_document_open;
pub(crate) use location_navigation::{
LocationNavigationKind,
dispatch_top_level_navigation_event_with_source_element, is_native_location,
navigate_borrowed_location, navigate_child_meta_refresh,
HyperlinkNavigationOptions, LocationNavigationKind, is_native_location,
navigate_borrowed_location, navigate_child_meta_refresh, navigate_location_object_for_hyperlink,
navigate_location_object_with_child_navigate_event,
history_entry_seed_for_cross_document_location,
navigate_location_object_with_child_navigate_event_and_initiator_url,
@@ -85,6 +85,40 @@ struct LocationNavigationOptions {
dispatch_child_navigate_event_for_all_kinds: bool,
force_exact_same_document_navigation: bool,
explicit_initiator_url: Option<url::Url>,
user_initiated: bool,
hyperlink_source_can_access_target: Option<bool>,
named_hyperlink_popup: Option<crate::native_bridge::element::NamedHyperlinkPopup>,
}
pub(crate) struct HyperlinkNavigationOptions {
pub(crate) user_initiated: bool,
pub(crate) source_can_access_target: bool,
pub(crate) initiator_url: url::Url,
pub(crate) named_popup: Option<crate::native_bridge::element::NamedHyperlinkPopup>,
}
pub(crate) fn navigate_location_object_for_hyperlink<'s>(
scope: &mut v8::PinScope<'s, '_>,
location: v8::Local<'s, v8::Object>,
href: &str,
source_element: v8::Local<'s, v8::Object>,
options: HyperlinkNavigationOptions,
) {
navigate_location_object_with_source_element_and_child_navigate_event(
scope,
location,
LocationNavigationKind::Assign,
Some(href.to_owned()),
Some(source_element),
LocationNavigationOptions {
user_initiated: options.user_initiated,
hyperlink_source_can_access_target: Some(options.source_can_access_target),
force_exact_same_document_navigation: true,
explicit_initiator_url: Some(options.initiator_url),
named_hyperlink_popup: options.named_popup,
..LocationNavigationOptions::default()
},
);
}
pub(crate) fn navigate_location_object<'s>(
@@ -347,7 +381,12 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
dispatch_child_navigate_event_for_all_kinds,
force_exact_same_document_navigation,
explicit_initiator_url,
user_initiated,
hyperlink_source_can_access_target,
named_hyperlink_popup,
} = options;
let event_source_element =
source_element.filter(|_| hyperlink_source_can_access_target != Some(false));
let current_href = location_href_slot(scope, location).unwrap_or_default();
let current_url = url::Url::parse(&current_href).ok();
let raw_target_is_fragment_only = raw_target
@@ -511,11 +550,11 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
),
true,
true,
false,
user_initiated,
None,
None,
None,
source_element,
event_source_element,
)
});
if navigate_outcome
@@ -546,6 +585,27 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
{
return;
}
// Preserve synchronous fragment activation only after the navigate event
// accepts it. Intercepted navigation owns its scroll timing separately.
if hyperlink_source_can_access_target.is_some()
&& runtime_window_is_global(scope, owner)
&& !navigate_outcome
.as_ref()
.is_some_and(|outcome| outcome.intercepted)
&& let Some(host_ptr) = context_host_ptr_from_global_bridge(scope)
&& let Err(error) = crate::native_bridge::element::scroll_to_document_fragment_target(
scope, host_ptr, &resolved,
)
{
if let Some(message) = v8_string(
scope,
&format!("Layout failed while scrolling to fragment: {error}"),
) {
let exception = v8::Exception::error(scope, message);
scope.throw_exception(exception);
}
return;
}
if let Some(navigation) = navigation {
cancel_pending_same_document_navigation_finishes(scope, navigation);
}
@@ -685,8 +745,12 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
&& resolved.scheme() == "javascript"
{
if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) {
let _ = unsafe { &mut *host_ptr }
.navigate_lightweight_popup_window_to_url(scope, popup_id, resolved, kind);
if let Some(popup) = named_hyperlink_popup {
popup.navigate(scope, host_ptr, resolved.as_str());
} else {
let _ = unsafe { &mut *host_ptr }
.navigate_lightweight_popup_window_to_url(scope, popup_id, resolved, kind);
}
}
return;
}
@@ -697,6 +761,7 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
child_browsing_context_handle_for_runtime_owner(scope, owner)
};
if child_handle.is_none()
&& hyperlink_source_can_access_target != Some(false)
&& let Some(navigation) =
super::navigation_window::window_navigation_for_holder(scope, owner)
{
@@ -731,12 +796,14 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
navigation_type,
false,
false,
true,
false,
current_url.as_ref().is_some_and(|current| {
super::history_mutation::document_can_have_url_rewritten(current, &resolved)
}),
user_initiated,
None,
destination_state,
None,
source_element,
event_source_element,
);
if outcome.abort_error.is_some() {
return;
@@ -769,8 +836,12 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
if let Some(popup_id) = popup_id {
if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) {
let _ = unsafe { &mut *host_ptr }
.navigate_lightweight_popup_window_to_url(scope, popup_id, resolved, kind);
if let Some(popup) = named_hyperlink_popup {
popup.navigate(scope, host_ptr, resolved.as_str());
} else {
let _ = unsafe { &mut *host_ptr }
.navigate_lightweight_popup_window_to_url(scope, popup_id, resolved, kind);
}
}
return;
}
@@ -790,6 +861,7 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
};
if (matches!(kind, LocationNavigationKind::Assign)
|| dispatch_child_navigate_event_for_all_kinds)
&& hyperlink_source_can_access_target != Some(false)
&& !is_javascript_url
&& let Some(window) = window_for_child_cross_document_location_navigation(scope, owner)
&& !dispatch_cross_document_navigation_navigate_event_for_window_with_type_and_form_data(
@@ -801,8 +873,8 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
LocationNavigationKind::Replace => "replace",
LocationNavigationKind::Reload => "reload",
},
source_element,
false,
event_source_element,
user_initiated,
None,
None,
)
@@ -841,14 +913,19 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
return;
}
if resolved.scheme() != "javascript" {
// A new top-level hyperlink leaves the source Location and history intact
// until the browser commits the response, just as the activation path did.
let browser_owned_hyperlink = hyperlink_source_can_access_target.is_some() && !exact_same_href;
if !browser_owned_hyperlink && resolved.scheme() != "javascript" {
sync_location_object(scope, location, resolved.as_str());
}
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
return;
};
let entry_seed = if matches!(kind, LocationNavigationKind::Reload) {
let entry_seed = if browser_owned_hyperlink {
None
} else if matches!(kind, LocationNavigationKind::Reload) {
history_entry_seed_for_reload(scope, owner)
} else {
history_entry_seed_for_cross_document_location(scope, owner, &resolved, kind)
@@ -1047,6 +1124,7 @@ pub(crate) fn dispatch_top_level_navigation_event_with_source_element<'s>(
can_intercept: bool,
user_initiated: bool,
download_request: Option<&str>,
form_data: Option<v8::Local<'s, v8::Value>>,
) -> bool {
let Some(navigation) = super::navigation_window::window_navigation_for_holder(scope, owner)
else {
@@ -1060,7 +1138,7 @@ pub(crate) fn dispatch_top_level_navigation_event_with_source_element<'s>(
cancel_pending_precommit_history_traversal(scope, navigation);
cancel_active_intercepted_same_document_navigation(scope, navigation);
cancel_pending_same_document_navigation_finishes_including_reentrant(scope, navigation);
let outcome = dispatch_navigation_navigate_event_with_outcome(
let outcome = dispatch_navigation_navigate_event_with_form_data_and_outcome(
scope,
navigation,
href,
@@ -1072,6 +1150,7 @@ pub(crate) fn dispatch_top_level_navigation_event_with_source_element<'s>(
download_request,
None,
None,
form_data,
source_element,
);
if outcome.abort_error.is_some() {
@@ -839,6 +839,13 @@ impl JsContextHost {
}
}
pub(crate) fn named_lightweight_popup_id(&self, target_name: &str) -> Option<u64> {
let name = trackable_lightweight_popup_window_name(target_name)?;
self.lightweight_popup_browsing_contexts.iter().find_map(|(id, record)| {
(record.is_open() && record.name_lookup_allowed && record.name.get() == name).then_some(*id)
})
}
pub(crate) fn open_lightweight_popup_window<'s>(
&mut self,
scope: &mut v8::PinScope<'s, '_>,
@@ -851,16 +858,7 @@ impl JsContextHost {
creator_policy_container: DocumentPolicyContainer,
update_existing_opener: bool,
) -> Option<OpenedLightweightPopup<'s>> {
if let Some(name) = trackable_lightweight_popup_window_name(target_name)
&& let Some(popup_id) =
self.lightweight_popup_browsing_contexts
.iter()
.find_map(|(id, record)| {
(record.is_open()
&& record.name_lookup_allowed
&& record.name.get() == name)
.then_some(*id)
})
if let Some(popup_id) = self.named_lightweight_popup_id(target_name)
{
let window = self.lightweight_popup_window(scope, popup_id)?;
if update_existing_opener && let Some(opener) = opener {
@@ -196,7 +196,7 @@ use super::document::{
};
pub(crate) use activation::perform_clipboard_key_default_action;
pub(crate) use activation::{
SpecialBrowsingContextTarget, navigate_existing_browsing_context_target,
NamedHyperlinkPopup, SpecialBrowsingContextTarget, navigate_existing_browsing_context_target,
navigate_named_iframe_target,
};
pub(crate) use activation::{
@@ -206,7 +206,7 @@ pub(crate) use activation::{
dispatched_click_activation_target, finish_legacy_activation_for_dispatched_click,
perform_auxiliary_link_default_action, perform_click_default_action_for_dispatched_event,
perform_drop_default_action, prepare_legacy_activation_for_dispatched_click,
replace_contenteditable_selection, scroll_to_url_fragment_or_top,
replace_contenteditable_selection, scroll_to_document_fragment_target, scroll_to_url_fragment_or_top,
select_contenteditable_contents,
};
pub(crate) use activation::{document_copy_command_supported, run_document_copy_command};
@@ -1,7 +1,4 @@
use crate::context_bootstrap::{
LocationNavigationKind, navigate_location_object_with_source_element,
readonly_data_transfer_for_input, selection_value_for_window,
};
use crate::context_bootstrap::{readonly_data_transfer_for_input, selection_value_for_window};
use crate::dom::{
forms::{ButtonTypeState, InputType},
native::{Element, Node, SelectedFile},
@@ -41,7 +38,7 @@ use super::super::{
};
use super::targets::{
SpecialBrowsingContextTarget, named_iframe_target_handle_for_navigation,
navigate_element_target_browsing_context, navigate_hyperlink_source_browsing_context,
navigate_element_target_browsing_context,
};
fn data_transfer_selected_files<'s>(
@@ -1900,66 +1897,6 @@ fn anchor_click_default_action(
unreachable!("download hyperlink policy returned before target selection")
}
};
let special_target = target_name
.as_deref()
.and_then(SpecialBrowsingContextTarget::parse);
if (target_name.is_none() || special_target == Some(SpecialBrowsingContextTarget::Current))
&& navigate_hyperlink_source_browsing_context(scope, runtime_ptr, handle, &resolved)
{
return None;
}
if (target_name.is_none() || special_target == Some(SpecialBrowsingContextTarget::Current))
&& runtime.host_document().url().as_str() == resolved
{
let global = scope.get_current_context().global(scope);
if let Some(location) = global
.get(scope, v8str(scope, "location").into())
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
{
let source_element = node_wrapper_from_handle(scope, handle);
navigate_location_object_with_source_element(
scope,
location,
LocationNavigationKind::Assign,
Some(resolved),
source_element,
);
}
return None;
}
if (target_name.is_none()
|| matches!(
special_target,
Some(
SpecialBrowsingContextTarget::Current
| SpecialBrowsingContextTarget::Top
| SpecialBrowsingContextTarget::Parent
)
))
&& let Ok(url) = url::Url::parse(&resolved)
&& same_document_fragment_target(runtime.host_document().url(), &url)
{
if let Err(error) = scroll_to_document_fragment_target(scope, runtime_ptr, &url) {
throw_activation_layout_error(scope, "scrolling to fragment", error);
return None;
}
let global = scope.get_current_context().global(scope);
if let Some(location) = global
.get(scope, v8str(scope, "location").into())
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
{
let source_element = node_wrapper_from_handle(scope, handle);
navigate_location_object_with_source_element(
scope,
location,
LocationNavigationKind::Assign,
Some(resolved),
source_element,
);
}
return None;
}
let source_element = node_wrapper_from_handle(scope, handle);
if click_listener_changed_named_child_navigation(
scope,
runtime_ptr,
@@ -1976,7 +1913,6 @@ fn anchor_click_default_action(
handle,
target_name.as_deref(),
&resolved,
source_element,
user_initiated,
popup_disposition,
);
@@ -2160,7 +2096,7 @@ pub(crate) fn scroll_to_url_fragment_or_top(
Ok(())
}
fn scroll_to_document_fragment_target(
pub(crate) fn scroll_to_document_fragment_target(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
target_url: &url::Url,
@@ -15,11 +15,11 @@ pub(crate) use default_action::{
dispatched_click_activation_target, finish_legacy_activation_for_dispatched_click,
perform_auxiliary_link_default_action, perform_click_default_action_for_dispatched_event,
perform_drop_default_action, prepare_legacy_activation_for_dispatched_click,
replace_contenteditable_selection, scroll_to_url_fragment_or_top,
replace_contenteditable_selection, scroll_to_document_fragment_target, scroll_to_url_fragment_or_top,
select_contenteditable_contents,
};
pub(in crate::native_bridge) use targets::choose_form_navigation_target;
pub(crate) use targets::{
SpecialBrowsingContextTarget, navigate_existing_browsing_context_target,
NamedHyperlinkPopup, SpecialBrowsingContextTarget, navigate_existing_browsing_context_target,
navigate_named_iframe_target,
};
@@ -71,6 +71,30 @@ struct ElementPopupRelations {
suppress_referrer: bool,
}
pub(crate) struct NamedHyperlinkPopup {
source_handle: DomHandle,
target_name: String,
disposition: RendererPopupDisposition,
}
impl NamedHyperlinkPopup {
pub(crate) fn navigate(
&self,
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
href: &str,
) {
navigate_element_popup_target(
scope,
runtime_ptr,
self.source_handle,
&self.target_name,
href,
self.disposition,
);
}
}
fn element_popup_relations(
runtime: &JsContextHost,
source_handle: DomHandle,
@@ -579,137 +603,137 @@ pub(crate) fn navigate_existing_browsing_context_target<'s>(
navigate_special_target_from_window(scope, source_window, Some(target), resolved_url)
}
pub(super) fn navigate_hyperlink_source_browsing_context(
/// Resolve an existing target without reading script-visible WindowProxy properties.
fn existing_hyperlink_target(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
source_handle: DomHandle,
resolved_url: &str,
) -> bool {
let Some(dispatch_scope) =
browsing_context_dispatch_scope_for_node(scope, runtime_ptr, source_handle)
else {
return false;
source: crate::native_bridge::OwnerDispatchScope,
target_name: Option<&str>,
) -> Option<crate::native_bridge::OwnerDispatchScope> {
use crate::native_bridge::OwnerDispatchScope;
let runtime = unsafe { &*runtime_ptr };
let Some(name) = target_name else {
return Some(source);
};
match dispatch_scope {
crate::native_bridge::OwnerDispatchScope::Top => false,
crate::native_bridge::OwnerDispatchScope::Child(handle) => unsafe { &mut *runtime_ptr }
.navigate_child_browsing_context_to_url(scope, handle, resolved_url),
crate::native_bridge::OwnerDispatchScope::LightweightPopup(popup_id) => {
let Some(source_window) =
unsafe { &*runtime_ptr }.lightweight_popup_window(scope, popup_id)
else {
return false;
};
navigate_special_target_from_window(
scope,
source_window,
Some(SpecialBrowsingContextTarget::Current),
Some(resolved_url),
)
.is_some()
match SpecialBrowsingContextTarget::parse(name) {
Some(SpecialBrowsingContextTarget::Current) => Some(source),
Some(
special @ (SpecialBrowsingContextTarget::Top | SpecialBrowsingContextTarget::Parent),
) => {
let mut target = source;
while let OwnerDispatchScope::Child(handle) = target {
target = runtime.owner_dispatch_scope_for_node(handle)?;
if special == SpecialBrowsingContextTarget::Parent {
break;
}
}
Some(target)
}
Some(SpecialBrowsingContextTarget::Blank) => None,
None => {
let document = runtime.dom_host().owner_document_handle(source_handle);
named_iframe_target_handle_for_navigation(scope, runtime_ptr, name, document)
.map(OwnerDispatchScope::Child)
.or_else(|| {
unsafe { &*runtime_ptr }
.named_lightweight_popup_id(name)
.map(OwnerDispatchScope::LightweightPopup)
})
}
}
}
pub(in crate::native_bridge) fn navigate_element_target_browsing_context<'s>(
scope: &mut v8::PinScope<'s, '_>,
pub(in crate::native_bridge) fn navigate_element_target_browsing_context(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
source_handle: DomHandle,
target_name: Option<&str>,
resolved_url: &str,
source_element: Option<v8::Local<'s, v8::Object>>,
user_initiated: bool,
popup_disposition: RendererPopupDisposition,
) -> bool {
use crate::native_bridge::OwnerDispatchScope;
if !element_javascript_url_allowed_by_csp(scope, runtime_ptr, source_handle, resolved_url) {
return true;
}
let special_target = target_name.and_then(SpecialBrowsingContextTarget::parse);
if special_target == Some(SpecialBrowsingContextTarget::Blank) {
return navigate_element_popup_target(
scope,
runtime_ptr,
source_handle,
"_blank",
resolved_url,
popup_disposition,
);
}
if let Some(target_name) = target_name
&& special_target.is_none()
{
let source_document = unsafe { &*runtime_ptr }
.dom_host()
.node(source_handle)
.and_then(|node| node.owner_document());
return navigate_named_iframe_target_from_document(
scope,
runtime_ptr,
target_name,
resolved_url,
source_document,
source_element,
) || navigate_element_popup_target(
scope,
runtime_ptr,
source_handle,
target_name,
resolved_url,
popup_disposition,
);
}
let Some(dispatch_scope) =
browsing_context_dispatch_scope_for_node(scope, runtime_ptr, source_handle)
else {
let Some(source) = unsafe { &*runtime_ptr }.owner_dispatch_scope_for_node(source_handle) else {
return false;
};
let Some(source_window) =
browsing_context_window_for_dispatch_scope(scope, runtime_ptr, dispatch_scope)
let Some(target) =
existing_hyperlink_target(scope, runtime_ptr, source_handle, source, target_name)
else {
return false;
return target_name.is_some_and(|name| {
navigate_element_popup_target(
scope,
runtime_ptr,
source_handle,
name,
resolved_url,
popup_disposition,
)
});
};
let Some(target_window) =
special_target_window_from_window(scope, source_window, special_target)
else {
return false;
};
if resolved_url.is_empty() {
let runtime = unsafe { &mut *runtime_ptr };
if runtime.sandbox_blocks_ancestor_navigation(source, target) {
return true;
}
if matches!(
runtime_window_dispatch_scope(scope, target_window),
Some(crate::native_bridge::OwnerDispatchScope::Top)
) {
// Check the hyperlink's native Document before any event or intercepted commit.
if let crate::native_bridge::OwnerDispatchScope::Child(source_frame) = dispatch_scope
&& sandbox_blocks_ancestor_or_top_navigation_from_source(
scope,
source_frame,
target_window,
)
{
return true;
}
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,
let Some(document) = runtime.dom_host().owner_document_handle(source_handle) else {
return false;
};
let initiator_url = runtime.document_url_for_handle(document);
let Some(source_element) = crate::util::node_wrapper_from_handle(scope, source_handle) else {
return false;
};
// Resolve the native target realm before reading its private Location slot.
// The source's access check controls event dispatch and sourceElement exposure.
let context = match target {
OwnerDispatchScope::Child(handle) => runtime
.ensure_prebootstrapped_child_default_context(scope, handle)
.ok(),
_ => runtime
.current_registered_window_execution_context_identity(target)
.and_then(|identity| runtime.window_execution_context(scope, identity.owner(), target))
.map(|(_, context)| context),
};
let Some(context) = context else { return false };
let can_access = runtime
.current_registered_window_execution_context_identity(source)
.is_some_and(|identity| {
runtime.window_execution_context_can_access_dispatch_scope(identity, target)
});
let scope = &mut v8::ContextScope::new(scope, context);
let window = match target {
OwnerDispatchScope::LightweightPopup(id) => runtime.lightweight_popup_window(scope, id),
_ => Some(context.global(scope)),
};
let Some(location) = window
.and_then(|window| crate::context_bootstrap::window_location_for_holder(scope, window))
else {
return false;
};
crate::context_bootstrap::navigate_location_object_for_hyperlink(
scope,
location,
resolved_url,
source_element,
crate::context_bootstrap::HyperlinkNavigationOptions {
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)
source_can_access_target: can_access,
initiator_url,
named_popup: target_name
.filter(|name| {
matches!(target, OwnerDispatchScope::LightweightPopup(_))
&& SpecialBrowsingContextTarget::parse(name).is_none()
})
.map(|name| NamedHyperlinkPopup {
source_handle,
target_name: name.to_owned(),
disposition: popup_disposition,
}),
},
);
true
}
pub(crate) fn navigate_named_iframe_target<'s>(
@@ -870,7 +870,7 @@ pub(in crate::native_bridge) fn apply_planned_form_navigation(
OwnerDispatchScope::Top =>
crate::context_bootstrap::dispatch_top_level_navigation_event_with_source_element(
scope, window, navigation.request.url().as_str(), navigation.mutation.navigation_type(),
Some(source_element), true, navigation.user_initiated, form_data,
Some(source_element), true, navigation.user_initiated, None, form_data,
),
OwnerDispatchScope::Child(_) | OwnerDispatchScope::LightweightPopup(_) =>
crate::context_bootstrap::dispatch_cross_document_navigation_navigate_event_for_window_with_type_and_form_data(
@@ -223,3 +223,22 @@ fn fragment_navigation_reveals_closed_details_ancestors_but_not_their_summary()
r##"{"targetState":[true,true,"#target"],"summaryState":[true,false,"#inner-summary"]}"##
);
}
#[test]
fn canceled_fragment_hyperlink_does_not_reveal_closed_details() {
let mut vm = new_parsed_test_vm(
"https://details-canceled-fragment.test/",
r##"<!doctype html><a id="link" href="#target">Go</a>
<details id="closed"><summary>Summary</summary><div id="target">Target</div></details>"##,
);
let result = vm
.eval(
r#"(() => {
navigation.onnavigate = event => event.preventDefault();
document.getElementById('link').click();
return JSON.stringify([document.getElementById('closed').open, location.hash]);
})()"#,
)
.expect("canceled fragment activation");
assert_eq!(result, r#"[false,""]"#);
}
@@ -3955,6 +3955,12 @@ fn anchor_click_queues_pending_top_level_location_navigation() {
)
.expect("anchor click should execute");
assert_eq!(
vm.eval("location.href")
.expect("source Location remains readable"),
"https://anchor-click-navigation.test/path/index.html",
"a pending hyperlink must not change the source URL before commit"
);
let pending = vm
.take_pending_location_navigation_with_seed()
.expect("anchor click should queue a pending location navigation");