fix: replace iframe src history before load completion

Use the active child Document's native complete-load state when choosing
whether iframe src navigation replaces the current history entry. This
covers parser, DOMContentLoaded, load and pageshow callbacks despite an
already complete readyState, while retaining post-load push behavior.

Add Browser coverage for 14 src assignment and setAttribute flows. Update
two existing history expectations for src changes from iframe load
callbacks, independently confirmed with their HTML fixtures in Chrome.

Validation: cargo fmt --all; cargo clippy --workspace --all-targets
--all-features -- -D warnings; cargo nextest run --no-fail-fast
(17915 passed, 13 skipped). The 222-case WPT comparison gains three passing
cases and three subtests without regressions; passed ledger is 9105.
This commit is contained in:
ldm0
2026-09-23 00:11:56 +08:00
parent fd483cc8f4
commit 0e07e58286
5 changed files with 126 additions and 10 deletions
@@ -2714,9 +2714,6 @@ html/browsers/browsing-the-web/navigating-across-documents/014.html
html/browsers/browsing-the-web/navigating-across-documents/abort-document-load.html
html/browsers/browsing-the-web/navigating-across-documents/initial-empty-document/iframe-src-204-fragment.html
html/browsers/browsing-the-web/navigating-across-documents/plugin-document.historical.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/iframe-src-during-load.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/iframe-src-during-pageshow.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/iframe-src.html
html/browsers/browsing-the-web/overlapping-navigations-and-traversals/cross-document-nav-same-document-traversal.html
html/browsers/browsing-the-web/overlapping-navigations-and-traversals/cross-document-nav-stop.html
html/browsers/browsing-the-web/overlapping-navigations-and-traversals/cross-document-traversal-cross-document-traversal.html
@@ -5716,6 +5716,9 @@ html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/f
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/history-pushstate-during-load.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/history-pushstate-during-pageshow.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/history-pushstate.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/iframe-src-during-load.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/iframe-src-during-pageshow.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/iframe-src.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/location-assign-during-load.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/location-assign-during-pageshow.html
html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/location-assign.html
+5 -5
View File
@@ -928,7 +928,7 @@ async fn child_browsing_context_attribute_navigation_preserves_local_history() -
page.serialize_html_async()
.await
.unwrap()
.contains("data-child-history-length=\"3\""),
.contains("data-child-history-length=\"2\""),
"{}",
page.serialize_html_async().await.unwrap()
);
@@ -938,7 +938,7 @@ async fn child_browsing_context_attribute_navigation_preserves_local_history() -
.unwrap()
.contains(&format!(
"data-child-location-after-back=\"{}\"",
server.url("/compat/window-child-browsing-context-target-name-b")
server.url("/compat/window-child-browsing-context-target-name-a")
)),
"{}",
page.serialize_html_async().await.unwrap()
@@ -949,7 +949,7 @@ async fn child_browsing_context_attribute_navigation_preserves_local_history() -
.unwrap()
.contains(&format!(
"data-child-document-location-after-back=\"{}\"",
server.url("/compat/window-child-browsing-context-target-name-b")
server.url("/compat/window-child-browsing-context-target-name-a")
)),
"{}",
page.serialize_html_async().await.unwrap()
@@ -960,7 +960,7 @@ async fn child_browsing_context_attribute_navigation_preserves_local_history() -
.unwrap()
.contains(&format!(
"data-child-current-entry-after-back=\"{}\"",
server.url("/compat/window-child-browsing-context-target-name-b")
server.url("/compat/window-child-browsing-context-target-name-a")
)),
"{}",
page.serialize_html_async().await.unwrap()
@@ -3987,7 +3987,7 @@ async fn child_browsing_context_fragment_navigation_persists_through_attribute_n
page.serialize_html_async()
.await
.unwrap()
.contains("data-child-history-length=\"3\""),
.contains("data-child-history-length=\"2\""),
"{}",
page.serialize_html_async().await.unwrap()
);
+109
View File
@@ -0,0 +1,109 @@
use anyhow::Result;
use moli_core::runtime::{Browser, BrowserConfig};
use moli_test_support::FixtureServer;
use serde_json::{Value, json};
use tokio::time::Duration;
use url::Url;
async fn iframe_attribute_history(phase: &str, api: &str) -> Result<Value> {
let child = r#"<!doctype html><body><script>
const phase = new URL(location.href).searchParams.get('phase');
const go = () => parent.navigateFrame();
if (phase === 'parser') go();
else if (phase === 'DOMContentLoaded') document.addEventListener(phase, go, {once: true});
else if (phase === 'load' || phase === 'pageshow') addEventListener(phase, go, {once: true});
else if (phase !== 'owner-load') addEventListener('load', () => setTimeout(() => {
if (phase === 'reopen') document.open();
go();
}, 0), {once: true});
</script>"#;
let child = serde_json::to_string(child)?.replace("</script>", "<\\/script>");
let markup = r#"<!doctype html><body><script>
window.finished = new Promise(resolve => window.finish = resolve);
const phase = __PHASE__;
const api = __API__;
const initialLength = history.length;
const frame = document.createElement('iframe');
const destination = new URL('/compat/child-dynamic-markup-document?label=destination&markup=' +
encodeURIComponent('<!doctype html><body>destination'), location.href).href;
window.navigateFrame = () => {
window.navigationReadiness = frame.contentDocument.readyState;
if (api === 'property') frame.src = destination;
else frame.setAttribute('src', destination);
};
frame.onload = () => {
if (frame.contentWindow.location.href === destination) setTimeout(() => {
const win = frame.contentWindow;
finish({delta: history.length - initialLength, readiness: navigationReadiness,
entries: win.navigation.entries().map(entry => new URL(entry.url).searchParams.get('label')),
index: win.navigation.currentEntry.index, activation: win.navigation.activation.navigationType});
}, 0);
else if (phase === 'owner-load') navigateFrame();
};
frame.src = '/compat/child-dynamic-markup-document?label=source&phase=' + phase +
'&markup=' + encodeURIComponent(__CHILD__);
document.body.append(frame);
</script>"#
.replace("__PHASE__", &serde_json::to_string(phase)?)
.replace("__API__", &serde_json::to_string(api)?)
.replace("__CHILD__", &child);
let server = FixtureServer::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut url = Url::parse(&server.url("/compat/child-dynamic-markup-document"))?;
url.query_pairs_mut().append_pair("markup", &markup);
let result = tokio::time::timeout(Duration::from_secs(10), async {
let mut page = browser.fetch(url.as_str()).await?;
page.evaluate_runtime_expression_with_await_async(
"finished.then(value => JSON.stringify(value))",
true,
)
.await
})
.await??;
let result = serde_json::from_str(result["value"].as_str().unwrap())?;
server.shutdown().await;
Ok(result)
}
#[tokio::test(flavor = "multi_thread")]
async fn iframe_src_changes_replace_history_until_child_load_finishes() -> Result<()> {
for api in ["property", "setAttribute"] {
for phase in [
"parser",
"DOMContentLoaded",
"load",
"pageshow",
"owner-load",
] {
let result = iframe_attribute_history(phase, api).await?;
let readiness = match phase {
"parser" => "loading",
"DOMContentLoaded" => "interactive",
_ => "complete",
};
assert_eq!(
result,
json!({"delta": 0, "readiness": readiness, "entries": ["destination"],
"index": 0, "activation": "replace"}),
"{phase}/{api}"
);
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn iframe_src_changes_push_history_after_load_even_when_document_is_reopened() -> Result<()> {
for api in ["property", "setAttribute"] {
for phase in ["after-load", "reopen"] {
let result = iframe_attribute_history(phase, api).await?;
assert_eq!(
result,
json!({"delta": 1, "readiness": if phase == "reopen" { "loading" } else { "complete" },
"entries": ["source", "destination"], "index": 1, "activation": "push"}),
"{phase}/{api}"
);
}
}
Ok(())
}
@@ -38,8 +38,15 @@ pub(in crate::native_bridge) fn update_iframe_snapshot_navigation(
handle,
previous_seed_snapshot,
);
let replace_current =
runtime.child_browsing_context_is_on_initial_about_blank_entry(handle);
// Attribute navigation replaces an incompletely loaded Document,
// including inside its Window load/pageshow callbacks. readyState
// alone cannot distinguish those callbacks from a completed load.
let replace_current = runtime
.child_browsing_context_is_on_initial_about_blank_entry(handle)
|| runtime
.child_browsing_context_document_handle(handle)
.and_then(|document| runtime.document_is_completely_loaded(document))
== Some(false);
if runtime.queue_child_browsing_context_navigation_from_existing_seed(
handle,
&navigation_target,