fix(history): preserve each queued History API traversal

Coalescing consecutive History API requests discarded intermediate
same-document traversals and their events when joint traversal plans
entered the shared queue. Keep these requests in order and derive the next
index from the last queued step, while retaining Navigation API promise
reuse.

Apply top-level traversals to the page's default Window when an isolated
realm initiated the request. Add local back/forward coverage across same-
and cross-document entries, and verify every event and microtask turn in
the existing history tests and fixture.

Fixes: f8f46bf48 (fix(history): coordinate complete joint traversal plans)
This commit is contained in:
ldm0
2026-09-24 06:45:08 +08:00
parent e070146d21
commit 51c9e1df63
10 changed files with 199 additions and 46 deletions
@@ -1,6 +1,6 @@
<!doctype html>
<html>
<body data-sync-state="" data-popstate-log="" data-timeout-state="">
<body data-sync-state="" data-popstate-log="" data-async-state="">
<script>
const popstateLog = [];
@@ -9,6 +9,13 @@
window.addEventListener("popstate", (event) => {
popstateLog.push(String(event.state?.step ?? "null"));
document.body.setAttribute("data-popstate-log", popstateLog.join(","));
if (popstateLog.length === 2) {
document.body.setAttribute(
"data-async-state",
String(history.state?.step ?? "null")
);
}
});
history.back();
@@ -19,13 +26,6 @@
String(history.state?.step ?? "null")
);
setTimeout(() => {
document.body.setAttribute(
"data-timeout-state",
String(history.state?.step ?? "null")
);
document.body.setAttribute("data-popstate-log", popstateLog.join(","));
}, 0);
</script>
</body>
</html>
+5 -5
View File
@@ -1062,14 +1062,14 @@ async fn history_back_traversal_is_not_synchronous() -> Result<()> {
}
#[tokio::test]
async fn history_same_turn_back_then_forward_coalesces_without_popstate() -> Result<()> {
async fn history_same_turn_back_then_forward_preserves_both_popstates() -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&server.url("/compat/history-back-forward-same-turn-coalesces"))
.fetch(&server.url("/compat/history-back-forward-same-turn-traverses-in-order"))
.await?;
wait_for_body_attribute(&browser, &mut page, "data-timeout-state", "2").await?;
wait_for_body_attribute(&browser, &mut page, "data-popstate-log", "1,2").await?;
assert!(
page.serialize_html_async()
@@ -1083,7 +1083,7 @@ async fn history_same_turn_back_then_forward_coalesces_without_popstate() -> Res
page.serialize_html_async()
.await
.unwrap()
.contains("data-timeout-state=\"2\""),
.contains("data-async-state=\"2\""),
"{}",
page.serialize_html_async().await.unwrap()
);
@@ -1091,7 +1091,7 @@ async fn history_same_turn_back_then_forward_coalesces_without_popstate() -> Res
page.serialize_html_async()
.await
.unwrap()
.contains("data-popstate-log=\"\""),
.contains("data-popstate-log=\"1,2\""),
"{}",
page.serialize_html_async().await.unwrap()
);
@@ -599,6 +599,115 @@ async fn joint_history_traversal_cross_document_before_same_document() {
assert_joint_history_multi_frame_traversal("a", false, false).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn queued_history_back_preserves_same_document_before_cross_document() {
assert_queued_history_same_then_cross_document(false).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn queued_history_forward_preserves_same_document_before_cross_document() {
assert_queued_history_same_then_cross_document(true).await;
}
async fn assert_queued_history_same_then_cross_document(forward: bool) {
let app = axum::Router::new()
.fallback(|| async { axum::response::Html("<!doctype html><body>history fixture") });
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mut ctx = TestContext::new();
load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank");
ctx.process_async(json!({
"id": 9350, "method": "Page.navigate", "sessionId": "SID-1",
"params": {"url": format!("http://{addr}/top")}
}))
.await;
assert!(take_response_by_id(&mut ctx, 9350)["error"].is_null());
wait_until_message(&mut ctx, Some("SID-1"), "history fixture load", |message| {
message["method"] == "Page.domContentEventFired"
})
.await;
joint_history_test_evaluate(
&mut ctx,
r#"
globalThis.frame = document.createElement('iframe');
globalThis.withFrameLoad = action => new Promise(resolve => {
frame.onload = () => resolve(true);
action();
});
globalThis.withFrameHash = action => new Promise(resolve => {
frame.contentWindow.addEventListener('hashchange', () => resolve(true), {once:true});
action();
});
frame.src = '/child?first';
withFrameLoad(() => document.body.append(frame));
"#,
)
.await;
// Separate protocol commands start the next navigation outside the
// preceding load handler, preserving push rather than replace semantics.
let hash = "withFrameHash(() => frame.contentWindow.location.hash = '#same')";
let load = "withFrameLoad(() => frame.contentWindow.location.search = '?second')";
for script in if forward { [hash, load] } else { [load, hash] } {
joint_history_test_evaluate(&mut ctx, script).await;
}
if forward {
joint_history_test_evaluate(
&mut ctx,
"withFrameLoad(() => frame.contentWindow.history.back())",
)
.await;
joint_history_test_evaluate(
&mut ctx,
"withFrameHash(() => frame.contentWindow.history.back())",
)
.await;
}
let method = if forward { "forward" } else { "back" };
let synchronous = joint_history_test_evaluate(
&mut ctx,
&format!(
r#"
globalThis.traversalEvents = [];
const snapshot = kind => [kind, frame.contentWindow.location.search,
frame.contentWindow.location.hash];
frame.contentWindow.addEventListener('hashchange', event => {{
const destination = new URL(event.newURL);
traversalEvents.push(['hash', destination.search, destination.hash]);
}}, {{once:true}});
globalThis.traversalsFinished = new Promise(resolve => {{
frame.onload = () => {{
traversalEvents.push(snapshot('load'));
resolve(traversalEvents);
}};
}});
frame.contentWindow.history.{method}();
frame.contentWindow.history.{method}();
[frame.contentWindow.location.search, frame.contentWindow.location.hash];
"#
),
)
.await;
assert_eq!(
synchronous,
if forward {
json!(["?first", ""])
} else {
json!(["?second", "#same"])
}
);
let events = joint_history_test_evaluate(&mut ctx, "traversalsFinished").await;
assert_eq!(
events,
if forward {
json!([["hash", "?first", "#same"], ["load", "?second", "#same"]])
} else {
json!([["hash", "?second", ""], ["load", "?first", ""]])
}
);
server.abort();
}
#[tokio::test(flavor = "multi_thread")]
async fn joint_history_traversal_same_document_before_cross_document() {
assert_joint_history_multi_frame_traversal("b", false, false).await;
@@ -589,7 +589,13 @@ fn history_traversal_target_window<'s>(
) -> Option<v8::Local<'s, v8::Object>> {
match target.dispatch_scope() {
crate::native_bridge::OwnerDispatchScope::Top => {
Some(scope.get_current_context().global(scope))
// An isolated realm may initiate this task, but the Page's
// default Window owns the history being traversed.
Some(
host.page_default_context(scope)
.unwrap_or_else(|| scope.get_current_context())
.global(scope),
)
}
crate::native_bridge::OwnerDispatchScope::Child(child_handle) => {
host.child_browsing_context_window_wrapper(scope, child_handle)
@@ -118,6 +118,7 @@ impl HistoryQueueState {
fn pending_history_traversal_target_index(&self, target: WindowTaskTarget) -> Option<u32> {
self.pending_history_traversal_tasks
.iter()
.rev()
.find_map(|queued| match &queued.action {
PendingHistoryTraversalAction::SameDocument(pending)
if pending.target == target =>
@@ -140,18 +141,22 @@ impl HistoryQueueState {
info: Option<v8::Global<v8::Value>>,
result: Option<PendingNavigationResult>,
) -> Option<RendererPageHistoryTraversalTaskId> {
if let Some(pending) = self
.pending_history_traversal_tasks
.iter_mut()
.find_map(|queued| match &mut queued.action {
PendingHistoryTraversalAction::SameDocument(pending)
if pending.target == target =>
{
Some(pending)
}
PendingHistoryTraversalAction::SameDocument(_)
| PendingHistoryTraversalAction::ChildCrossDocument(_) => None,
})
// History API requests are ordered steps, including a same-document
// traversal followed by a cross-document traversal. Only Navigation
// API requests with result promises can share a pending task.
if result.is_some()
&& let Some(pending) =
self.pending_history_traversal_tasks
.iter_mut()
.find_map(|queued| match &mut queued.action {
PendingHistoryTraversalAction::SameDocument(pending)
if pending.target == target && !pending.results.is_empty() =>
{
Some(pending)
}
PendingHistoryTraversalAction::SameDocument(_)
| PendingHistoryTraversalAction::ChildCrossDocument(_) => None,
})
{
pending.target_index = target_index;
pending.joint_step = joint_step;
@@ -72,9 +72,10 @@ history.back();
}
#[tokio::test(flavor = "current_thread")]
async fn history_back_coalesces_into_one_typed_turn_and_never_enters_page_timer() {
async fn history_back_preserves_each_typed_turn_and_never_enters_page_timer() {
run_page_vm_async_test(async move {
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let document_url = Url::parse("https://example.com/history-typed-turn").unwrap();
let (mut page_vm, _resource_source, _owner_wake_rx) =
page_vm_with_bound_task_sources_and_owner_wake(&loader, document_url);
@@ -112,28 +113,45 @@ location.hash
!page_vm.vm().has_ready_timeout(),
"history traversal admission must not manufacture a PageTimer descriptor"
);
assert_eq!(
page_vm.vm().ms_to_next_timeout(),
None
);
assert_eq!(page_vm.vm().ms_to_next_timeout(), None);
assert!(
page_vm
.run_exact_selected_page_task_for_test(PageSelectedTaskTestSelector::HistoryTraversal, &loader)
.run_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::HistoryTraversal,
&loader
)
.await?,
"the coalesced traversal should consume one production selected task"
"the first traversal should consume one production selected task"
);
assert_eq!(page_vm.vm_mut().eval("location.hash")?, "#one");
assert_eq!(
page_vm.vm_mut().eval("__historyTurnLog.join('|')")?,
"popstate:#one|microtask:#one",
"the selected traversal must checkpoint its event microtasks before the next turn"
);
assert!(
page_vm
.run_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::HistoryTraversal,
&loader
)
.await?,
"the second traversal should retain its own production selected task"
);
assert_eq!(page_vm.vm_mut().eval("location.hash")?, "");
assert_eq!(
page_vm.vm_mut().eval("__historyTurnLog.join('|')")?,
"popstate:|microtask:",
"the selected traversal must checkpoint its event microtasks before the next turn"
"popstate:#one|microtask:#one|popstate:|microtask:"
);
assert!(
!page_vm
.run_exact_selected_page_task_for_test(PageSelectedTaskTestSelector::HistoryTraversal, &loader)
.run_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::HistoryTraversal,
&loader
)
.await?,
"two pending history.back() calls for one LocalWindow must coalesce into one source position"
"the history source should drain after both ordered traversal tasks"
);
Ok::<_, anyhow::Error>(())
})
@@ -1345,7 +1345,7 @@ result.finished.catch(error => __lmClosedHistoryRoute.push("finished:" + error.n
}
#[tokio::test]
async fn history_back_calls_from_default_and_isolated_world_coalesce_per_window() {
async fn history_back_calls_from_default_and_isolated_world_preserve_each_step() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm =
new_storage_page_task_executor_test_vm_with_loader("https://example.com/base", &loader);
@@ -1379,9 +1379,24 @@ history.pushState(null, "", "#two");
.await
.expect("default realm traversal should run")
);
assert_eq!(
vm.eval("location.hash")
.expect("first queued traversal should preserve its destination"),
"#one"
);
assert!(
vm.run_one_history_traversal_executor_turn(&loader)
.await
.expect("isolated realm traversal should run after the default realm traversal")
);
assert_eq!(
vm.eval("location.hash")
.expect("second queued traversal should reach the initial entry"),
""
);
assert!(
!vm.run_one_history_traversal_executor_turn(&loader)
.await
.expect("history source should be drained after one Window traversal position")
.expect("history source should be drained after both traversals")
);
}
+2 -2
View File
@@ -172,8 +172,8 @@ const HISTORY_BACK_SAME_TURN_TRAVERSES_ASYNCHRONOUSLY_HTML: &str = include_str!(
const HISTORY_BACK_IGNORES_PAGE_TAMPERED_QUEUE_MICROTASK_HTML: &str = include_str!(
"../../moli-core/tests/fixtures/runtime/history_back_ignores_page_tampered_queue_microtask.html"
);
const HISTORY_BACK_FORWARD_SAME_TURN_COALESCES_HTML: &str = include_str!(
"../../moli-core/tests/fixtures/runtime/history_back_forward_same_turn_coalesces.html"
const HISTORY_BACK_FORWARD_SAME_TURN_TRAVERSES_IN_ORDER_HTML: &str = include_str!(
"../../moli-core/tests/fixtures/runtime/history_back_forward_same_turn_traverses_in_order.html"
);
const HISTORY_STATE_MUTATION_DOES_NOT_MUTATE_STORED_SNAPSHOT_HTML: &str = include_str!(
"../../moli-core/tests/fixtures/runtime/history_state_mutation_does_not_mutate_stored_snapshot.html"
+2 -2
View File
@@ -776,8 +776,8 @@ pub(super) async fn history_back_ignores_page_tampered_queue_microtask_page() ->
Html(HISTORY_BACK_IGNORES_PAGE_TAMPERED_QUEUE_MICROTASK_HTML)
}
pub(super) async fn history_back_forward_same_turn_coalesces_page() -> Html<&'static str> {
Html(HISTORY_BACK_FORWARD_SAME_TURN_COALESCES_HTML)
pub(super) async fn history_back_forward_same_turn_traverses_in_order_page() -> Html<&'static str> {
Html(HISTORY_BACK_FORWARD_SAME_TURN_TRAVERSES_IN_ORDER_HTML)
}
pub(super) async fn history_state_mutation_does_not_mutate_stored_snapshot_page()
+2 -2
View File
@@ -235,8 +235,8 @@ pub(super) fn build_router() -> Router {
get(history_back_ignores_page_tampered_queue_microtask_page),
)
.route(
"/compat/history-back-forward-same-turn-coalesces",
get(history_back_forward_same_turn_coalesces_page),
"/compat/history-back-forward-same-turn-traverses-in-order",
get(history_back_forward_same_turn_traverses_in_order_page),
)
.route(
"/compat/history-state-mutation-does-not-mutate-stored-snapshot",