fix(history): preserve forward entries on cross-document replacement

This commit is contained in:
ldm0
2026-09-23 00:14:10 +08:00
parent 8d02c41333
commit fbbfcd18f0
4 changed files with 144 additions and 2 deletions
+41 -2
View File
@@ -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::<Vec<_>>();
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(
@@ -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}"
);
}
}
@@ -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;
@@ -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;
}