From fbbfcd18f0fc3900e6beea8160bfc30eb264d53d Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 22 Sep 2026 01:21:04 +0800 Subject: [PATCH] fix(history): preserve forward entries on cross-document replacement --- moli-page-types/src/navigation_history.rs | 43 ++++++++++++++- .../tests/history_replace_forward.rs | 54 +++++++++++++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../tests/fixtures/history-replace-forward.js | 48 +++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/history_replace_forward.rs create mode 100644 moli-renderer-v8/tests/fixtures/history-replace-forward.js diff --git a/moli-page-types/src/navigation_history.rs b/moli-page-types/src/navigation_history.rs index e63ae9171..192b2416e 100644 --- a/moli-page-types/src/navigation_history.rs +++ b/moli-page-types/src/navigation_history.rs @@ -442,10 +442,9 @@ pub fn cross_document_navigation_seed( .iter() .find(|entry| entry.history_index == current_index) .cloned(); - entries.retain(|entry| entry.history_index <= current_index); - let (destination_entry, destination_index) = match mutation { NavigationHistoryMutation::Push => { + entries.retain(|entry| entry.history_index <= current_index); let next_index = current_index + 1; let entry = navigation_history_entry( destination_url.as_str(), @@ -937,6 +936,46 @@ mod tests { ); } + #[test] + fn cross_document_navigation_seed_replace_preserves_forward_history() { + let entries = (0..4) + .map(|index| { + let mut entry = navigation_history_entry( + &format!("https://example.test/page-{index}"), + index, + index, + NavigationHistoryDocumentId::allocate(), + NavigationHistoryEntryId::allocate(), + NavigationHistoryEntryKey::allocate(), + Some(format!("{{\"classic\":{index}}}")), + Some(format!("{{\"navigation\":{index}}}")), + ); + entry.referrer_policy = Some("no-referrer".to_owned()); + entry + }) + .collect::>(); + for destination in [ + "https://example.test/replaced", + "https://other.test/replaced", + ] { + let seed = cross_document_navigation_seed( + entries.clone(), + 1, + 1, + &Url::parse(destination).unwrap(), + NavigationHistoryMutation::Replace, + ); + assert_eq!(seed.current_index, 1); + assert_eq!(seed.entries.len(), entries.len()); + assert_eq!(seed.entries[0], entries[0]); + assert_eq!(seed.entries[2..], entries[2..]); + assert_eq!(seed.entries[1].url, destination); + assert_ne!(seed.entries[1].document_id, entries[1].document_id); + assert_ne!(seed.entries[1].id, entries[1].id); + assert_eq!(seed.activation.as_ref().unwrap().entry, seed.entries[1]); + } + } + #[test] fn reload_navigation_seed_activates_current_entry_from_itself() { let entries = vec![navigation_history_entry( diff --git a/moli-renderer-v8/src/script_vm/tests/history_replace_forward.rs b/moli-renderer-v8/src/script_vm/tests/history_replace_forward.rs new file mode 100644 index 000000000..78c0e6d5a --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/history_replace_forward.rs @@ -0,0 +1,54 @@ +use super::*; + +const HISTORY_REPLACE_FORWARD: &str = + include_str!("../../../tests/fixtures/history-replace-forward.js"); + +#[tokio::test] +async fn cross_document_replace_preserves_forward_entries_and_states() { + for method in ["location", "navigation"] { + let server = StaticHttpServer::spawn(7).await; + let base = server.base_url().origin().ascii_serialization(); + let loader = static_http_loader([]); + let mut vm = + new_storage_page_task_executor_test_vm_with_loader(&format!("{base}/parent"), &loader); + vm.eval(&format!( + "{HISTORY_REPLACE_FORWARD}\n\ + globalThis.replaceForwardResult = 'pending';\n\ + historyReplaceForward({base:?}, {method:?}).then(\n\ + value => replaceForwardResult = value,\n\ + error => replaceForwardResult = String(error));" + )) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(replaceForwardResult !== 'pending')", + "true", + method, + ) + .await; + let result: serde_json::Value = + serde_json::from_str(&vm.eval("JSON.stringify(replaceForwardResult)").unwrap()) + .unwrap(); + assert_eq!( + result, + serde_json::json!({ + "paths": ["/a", "/replaced", "/c"], + "backwardIdentity": true, + "replacementIdentity": true, + "forwardIdentity": true, + "savedState": {"navigation": "forward"}, + "forwardPath": "/c", + "classicState": {"classic": "forward"}, + "navigationState": {"navigation": "forward"}, + "replacementPath": "/replaced", + }), + "{method}" + ); + assert_eq!( + server.finish_targets().await, + ["/a", "/b", "/c", "/b", "/replaced", "/c", "/replaced"], + "{method}" + ); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 6db1bca74..6d6b83e89 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -15711,6 +15711,7 @@ mod fetch_request_guard; mod frame_element_security; mod headers_list; mod history_referrer; +mod history_replace_forward; mod http_fixture; mod indexed_db; mod inspector_unwrap; diff --git a/moli-renderer-v8/tests/fixtures/history-replace-forward.js b/moli-renderer-v8/tests/fixtures/history-replace-forward.js new file mode 100644 index 000000000..7d9ae33d5 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/history-replace-forward.js @@ -0,0 +1,48 @@ +async function historyReplaceForward(base, method) { + const frame = document.createElement('iframe'); + const loaded = () => new Promise(resolve => frame.addEventListener('load', resolve, {once: true})); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const initial = loaded(); + frame.src = base + '/a'; + document.body.appendChild(frame); + await initial; + async function navigate(action) { + await settle(); + const completion = loaded(); + action(frame.contentWindow); + await completion; + await settle(); + } + await navigate(w => w.location.href = base + '/b'); + await navigate(w => w.location.href = base + '/c'); + frame.contentWindow.history.replaceState({classic: 'forward'}, ''); + frame.contentWindow.navigation.updateCurrentEntry({state: {navigation: 'forward'}}); + const before = frame.contentWindow.navigation.entries().map(entry => ({ + id: entry.id, key: entry.key, url: entry.url, + })); + await navigate(w => w.history.back()); + await navigate(w => { + if (method === 'location') w.location.replace(base + '/replaced'); + else w.navigation.navigate(base + '/replaced', {history: 'replace'}); + }); + const entries = frame.contentWindow.navigation.entries(); + const result = { + paths: entries.map(entry => new URL(entry.url).pathname), + backwardIdentity: entries[0].id === before[0].id && entries[0].key === before[0].key, + replacementIdentity: entries[1].id !== before[1].id && entries[1].key === before[1].key, + forwardIdentity: entries.length === 3 && entries[2].id === before[2].id && entries[2].key === before[2].key, + }; + if (!result.forwardIdentity) { + frame.remove(); + return result; + } + result.savedState = entries[2].getState(); + await navigate(w => w.history.forward()); + result.forwardPath = new URL(frame.contentWindow.location.href).pathname; + result.classicState = frame.contentWindow.history.state; + result.navigationState = frame.contentWindow.navigation.currentEntry.getState(); + await navigate(w => w.history.back()); + result.replacementPath = new URL(frame.contentWindow.location.href).pathname; + frame.remove(); + return result; +}