diff --git a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs index 58013f776c..a9aaedb6be 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs @@ -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, ) { 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, ) { 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, source_element: Option>, - 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 diff --git a/moli-renderer-v8/src/context_bootstrap/location_runtime.rs b/moli-renderer-v8/src/context_bootstrap/location_runtime.rs index c4a801dd6d..d9ae4ec5ee 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_runtime.rs @@ -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}; diff --git a/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs b/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs index 8ab299665e..fca5ac676d 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs @@ -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(); diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/history_api.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/history_api.rs index ba3a1c977d..d293f6364e 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/history_api.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/history_api.rs @@ -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::(&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 { + const root = document.documentElement || + document.appendChild(document.createElement('html')); + const frame = document.createElement('iframe'); + frame.id = 'hash-clear-srcdoc'; + frame.srcdoc = '

child

'; + 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::(&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(