fix(navigation): keep hash setters within the current document

Clearing Location.hash produced a URL without a target fragment and
entered the full-URL navigation path, interrupting load/harness completion and
replacing srcdoc documents. Preserve explicit hash-setter intent in the shared
Location route and compare encoded fragments before taking any navigation.

Keep the main branch's full href/assign no-fragment behavior and retired-facade
checks. Cover top-level hash removal, canonical hash reassignment, and srcdoc
Document identity, origin and NavigateEvent metadata.

Fixes: 536fa76aee (PR #1701)

Validate this independent patch on main ce0781f2ad:
- cargo fmt --all
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo nextest run --no-fail-fast
     Summary [ 493.678s] 20436 tests run: 20436 passed (9 slow), 18 skipped

Original-commit: 9446beaf8c
This commit is contained in:
ldm0
2026-10-21 22:28:15 +08:00
committed by Donough Liu
parent f660ee584f
commit e970d26b08
4 changed files with 184 additions and 15 deletions
@@ -1,6 +1,6 @@
use super::location_runtime::{
is_same_document_fragment_navigation, location_href_slot, resolve_location_navigation_target,
sync_location_object,
sync_location_object, urls_equal_ignoring_fragment,
};
use super::navigation_activation::install_navigation_transition;
use super::navigation_callbacks::cancel_active_intercepted_same_document_navigation;
@@ -48,6 +48,13 @@ pub(crate) enum LocationNavigationKind {
Reload,
}
#[derive(Default)]
struct LocationNavigationOptions {
dispatch_child_navigate_event_for_all_kinds: bool,
force_exact_same_document_navigation: bool,
hash_setter: bool,
}
pub(crate) fn resolve_cross_window_location_target(
scope: &mut v8::PinScope<'_, '_>,
raw: &str,
@@ -84,7 +91,30 @@ pub(crate) fn navigate_location_object<'s>(
raw_target: Option<String>,
) {
navigate_location_object_with_source_element_and_child_navigate_event(
scope, location, kind, raw_target, None, false, false,
scope,
location,
kind,
raw_target,
None,
LocationNavigationOptions::default(),
);
}
pub(in crate::context_bootstrap) fn navigate_location_hash<'s>(
scope: &mut v8::PinScope<'s, '_>,
location: v8::Local<'s, v8::Object>,
target: String,
) {
navigate_location_object_with_source_element_and_child_navigate_event(
scope,
location,
LocationNavigationKind::Assign,
Some(target),
None,
LocationNavigationOptions {
hash_setter: true,
..Default::default()
},
);
}
@@ -131,8 +161,10 @@ pub(crate) fn navigate_top_level_same_document_from_browser(
kind,
Some(target),
None,
false,
true,
LocationNavigationOptions {
force_exact_same_document_navigation: true,
..Default::default()
},
);
true
}
@@ -186,8 +218,10 @@ pub(crate) fn navigate_top_level_meta_refresh(
kind,
Some(target.to_string()),
None,
false,
same_document_fragment,
LocationNavigationOptions {
force_exact_same_document_navigation: same_document_fragment,
..Default::default()
},
);
context_host_ptr_from_global_bridge(scope)
.is_some_and(|host_ptr| unsafe { &*host_ptr }.has_pending_location_navigation())
@@ -208,8 +242,7 @@ pub(crate) fn navigate_location_object_with_source_element<'s>(
kind,
raw_target,
source_element,
false,
false,
LocationNavigationOptions::default(),
);
}
@@ -220,7 +253,15 @@ pub(crate) fn navigate_location_object_with_child_navigate_event<'s>(
raw_target: Option<String>,
) {
navigate_location_object_with_source_element_and_child_navigate_event(
scope, location, kind, raw_target, None, true, false,
scope,
location,
kind,
raw_target,
None,
LocationNavigationOptions {
dispatch_child_navigate_event_for_all_kinds: true,
..Default::default()
},
);
}
@@ -230,9 +271,13 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
kind: LocationNavigationKind,
raw_target: Option<String>,
source_element: Option<v8::Local<'s, v8::Object>>,
dispatch_child_navigate_event_for_all_kinds: bool,
force_exact_same_document_navigation: bool,
options: LocationNavigationOptions,
) {
let LocationNavigationOptions {
dispatch_child_navigate_event_for_all_kinds,
force_exact_same_document_navigation,
hash_setter,
} = options;
let owner = runtime_window_owner(scope, location);
if let Some(context) = location.get_creation_context(scope)
&& let Some(host_ptr) = crate::util::context_host_ptr_from_context_slot(context)
@@ -300,7 +345,7 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
if !matches!(kind, LocationNavigationKind::Reload)
&& exact_same_href
&& source_element.is_none()
&& raw_target_is_fragment_only
&& (raw_target_is_fragment_only || hash_setter)
&& !force_exact_same_document_navigation
{
return;
@@ -337,9 +382,18 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
return;
}
// A hash setter can remove the fragment entirely. Full URL assignments
// without a target fragment still take the cross-document route.
let same_document_fragment = if hash_setter {
current_url
.as_ref()
.is_some_and(|current| urls_equal_ignoring_fragment(current, &resolved))
} else {
is_same_document_fragment_navigation(current_url.as_ref(), &resolved)
};
if !matches!(kind, LocationNavigationKind::Reload)
&& (!exact_same_href || force_exact_same_document_navigation)
&& is_same_document_fragment_navigation(current_url.as_ref(), &resolved)
&& same_document_fragment
{
let opaque_origin = navigation_document_has_opaque_origin(scope, owner);
if !opaque_origin
@@ -1,5 +1,5 @@
use super::location_navigation::{
LocationNavigationKind, navigate_location_object,
LocationNavigationKind, navigate_location_hash, navigate_location_object,
navigate_location_object_with_child_navigate_event,
};
use super::navigation_callbacks::{document_location_getter, document_location_setter};
@@ -395,7 +395,7 @@ fn location_writable_attribute_setter_callback<'s>(
rv.set_undefined();
return;
}
navigate_location_object(scope, holder, LocationNavigationKind::Assign, Some(target));
navigate_location_hash(scope, holder, target);
}
LocationAttribute::Search => {
let query = value.strip_prefix('?').unwrap_or(&value).to_owned();
@@ -394,6 +394,121 @@ fn cross_document_pending_navigation_slot_is_not_script_writable() {
);
}
#[test]
fn location_hash_clear_stays_in_the_current_document() {
let mut vm = new_storage_test_vm("https://location-hash-clear.test/index.html#start");
let result = vm
.eval(
r#"
(() => {
const before = document;
const events = [];
navigation.onnavigate = event => events.push([
event.destination.sameDocument, event.hashChange
]);
location.hash = '';
return JSON.stringify({
href: location.href,
hash: location.hash,
sameDocument: document === before,
events
});
})()
"#,
)
.expect("clearing a hash should navigate within the existing Document");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!({
"href": "https://location-hash-clear.test/index.html",
"hash": "",
"sameDocument": true,
"events": [[true, true]]
})
);
assert!(vm.take_pending_location_navigation_with_seed().is_none());
}
#[test]
fn location_hash_reassignment_compares_the_encoded_fragment() {
let mut vm = new_storage_test_vm("https://location-same-hash.test/index.html#te%3Cst");
let result = vm
.eval(
r#"
(() => {
let events = 0;
navigation.onnavigate = () => ++events;
location.hash = 'te<st';
return JSON.stringify([location.href, events]);
})()
"#,
)
.expect("reassigning the same encoded hash should be a no-op");
assert_eq!(
result,
r#"["https://location-same-hash.test/index.html#te%3Cst",0]"#
);
assert!(vm.take_pending_location_navigation_with_seed().is_none());
}
#[tokio::test]
async fn clearing_a_srcdoc_hash_preserves_the_child_document_and_origin() {
let mut vm = new_storage_test_vm("https://srcdoc-hash-clear.test/");
vm.eval(
r#"
(() => {
const root = document.documentElement ||
document.appendChild(document.createElement('html'));
const frame = document.createElement('iframe');
frame.id = 'hash-clear-srcdoc';
frame.srcdoc = '<p id="start">child</p>';
root.appendChild(frame);
})()
"#,
)
.expect("srcdoc child should be created");
run_child_navigation_commit_and_host_load_for_test(&mut vm, "srcdoc hash clear").await;
let result = vm
.eval(
r#"
(() => {
const hashChild = document.getElementById('hash-clear-srcdoc').contentWindow;
const initialHref = hashChild.location.href;
const before = hashChild.document;
const events = [];
hashChild.navigation.onnavigate = event => events.push([
event.destination.sameDocument, event.hashChange
]);
hashChild.location.hash = 'start';
hashChild.location.hash = '';
return JSON.stringify({
initialHref,
href: hashChild.location.href,
sameDocument: hashChild.document === before,
sameOrigin: hashChild.origin === window.origin,
events
});
})()
"#,
)
.expect("clearing a srcdoc hash must keep the original child accessible");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!({
"initialHref": "about:srcdoc",
"href": "about:srcdoc",
"sameDocument": true,
"sameOrigin": true,
"events": [[true, true], [true, true]]
})
);
assert!(
!vm.run_child_frame_task_source_once_for_test(ChildFrameSemanticTurnKind::NavigationCommit)
.await,
"hash-only changes must not queue a child document replacement"
);
}
#[test]
fn location_hash_empty_fragment_serializes_empty_and_clears_target() {
let mut vm = new_parsed_test_vm(