diff --git a/moli-protocol/src/domains/page/tests/same_document_history.rs b/moli-protocol/src/domains/page/tests/same_document_history.rs
index a229d07115..e9a44f51b9 100644
--- a/moli-protocol/src/domains/page/tests/same_document_history.rs
+++ b/moli-protocol/src/domains/page/tests/same_document_history.rs
@@ -1,6 +1,7 @@
use super::*;
mod child_history_traversal;
+mod hyperlink_navigation;
mod popup_network_navigation;
mod response_gate;
mod stale_history_traversal;
diff --git a/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.js b/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.js
new file mode 100644
index 0000000000..7d5b9de40c
--- /dev/null
+++ b/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.js
@@ -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};
+}
diff --git a/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.rs b/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.rs
new file mode 100644
index 0000000000..50229a11c1
--- /dev/null
+++ b/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.rs
@@ -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#""#, 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}");
+ }
+ }
+}
diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs
index 2c5ebd504a..1eb86e92ca 100644
--- a/moli-renderer-v8/src/context_bootstrap.rs
+++ b/moli-renderer-v8/src/context_bootstrap.rs
@@ -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,
diff --git a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs
index 4471dc4d15..8b5ca7c3e3 100644
--- a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs
+++ b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs
@@ -85,6 +85,40 @@ struct LocationNavigationOptions {
dispatch_child_navigate_event_for_all_kinds: bool,
force_exact_same_document_navigation: bool,
explicit_initiator_url: Option,
+ user_initiated: bool,
+ hyperlink_source_can_access_target: Option,
+ named_hyperlink_popup: Option,
+}
+
+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,
+}
+
+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(¤t_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>,
) -> 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() {
diff --git a/moli-renderer-v8/src/native_bridge/context_host/popups.rs b/moli-renderer-v8/src/native_bridge/context_host/popups.rs
index 6c0d5afa33..fe93991340 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs
@@ -839,6 +839,13 @@ impl JsContextHost {
}
}
+ pub(crate) fn named_lightweight_popup_id(&self, target_name: &str) -> Option {
+ 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> {
- 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 {
diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs
index c75b10a8a5..b09ed2c5ef 100644
--- a/moli-renderer-v8/src/native_bridge/element.rs
+++ b/moli-renderer-v8/src/native_bridge/element.rs
@@ -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};
diff --git a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs
index 288abaa145..3d2af8be1b 100644
--- a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs
+++ b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs
@@ -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::::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::::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,
diff --git a/moli-renderer-v8/src/native_bridge/element/activation/mod.rs b/moli-renderer-v8/src/native_bridge/element/activation/mod.rs
index ac5a7ebad5..c3eb8bddfb 100644
--- a/moli-renderer-v8/src/native_bridge/element/activation/mod.rs
+++ b/moli-renderer-v8/src/native_bridge/element/activation/mod.rs
@@ -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,
};
diff --git a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs
index 472d8f7a98..0bcc5215e6 100644
--- a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs
+++ b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs
@@ -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 {
+ 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>,
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>(
diff --git a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs
index 518cb49a98..19c2c30091 100644
--- a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs
+++ b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs
@@ -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(
diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/details.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/details.rs
index 489f1304c7..0e5d211a99 100644
--- a/moli-renderer-v8/src/script_vm/tests/browser_api/details.rs
+++ b/moli-renderer-v8/src/script_vm/tests/browser_api/details.rs
@@ -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##"Go
+ Summary
Target
"##,
+ );
+ 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,""]"#);
+}
diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs
index dd41457798..365cb0b70e 100644
--- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs
+++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs
@@ -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");