fix(dom): update document.open URLs from the entry document

This commit is contained in:
ldm0
2026-09-23 00:14:09 +08:00
parent 51455017c5
commit 6f3fdb80d4
10 changed files with 400 additions and 10 deletions
+30
View File
@@ -0,0 +1,30 @@
(async () => {
const frame = document.createElement('iframe');
frame.src = '/compat/child-dynamic-markup-document?markup=%3Cbody%3Esource#source-fragment';
const loaded = new Promise(resolve => frame.onload = resolve);
document.body.append(frame);
await loaded;
const child = frame.contentWindow;
return new Promise(resolve => {
child.finish = resolve;
child.target = document;
child.targetWindow = window;
child.setTimeout(child.Function(`
const done = finish, doc = target, win = targetWindow;
const expected = document.URL.split('#')[0];
const oldLength = win.history.length;
const returned = doc.open();
const observations = {url: doc.URL, uri: doc.documentURI, base: doc.baseURI,
location: win.location.href, length: win.history.length, identity: returned === doc};
const failures = [];
for (const field of ['url', 'uri', 'base', 'location']) {
if (observations[field] !== expected) failures.push({field, actual: observations[field], expected});
}
if (observations.length !== oldLength) failures.push({field: 'length', actual: observations.length, expected: oldLength});
if (!observations.identity) failures.push({field: 'identity', actual: false, expected: true});
doc.write('<!doctype html><body>replacement');
doc.close();
done({checks: 6, failures, observations});
`), 0);
});
})()
+97
View File
@@ -0,0 +1,97 @@
(async () => {
let checks = 0;
const failures = [], observations = [];
const check = (name, actual, expected) => {
checks++;
if (JSON.stringify(actual) !== JSON.stringify(expected)) failures.push({name, actual, expected});
};
const frame = async (src) => {
const node = document.createElement('iframe');
if (src) node.src = src;
const loaded = new Promise(resolve => node.onload = resolve);
document.body.append(node);
await loaded;
return node;
};
const snapshot = (doc, win) => ({
url: doc.URL, uri: doc.documentURI, base: doc.baseURI,
location: win.location.href, length: win.history.length, state: win.history.state,
navigation: win.navigation?.currentEntry?.url ?? null,
link: new win.URL('relative', doc.baseURI).href,
});
const parentURL = new URL(document.URL); parentURL.hash = '';
for (const mode of ['blank-open', 'loaded-open', 'loaded-write', 'base-open', 'borrowed-open', 'self-open']) {
const f = await frame(mode === 'blank-open' ? null : '/compat/child-dynamic-markup-document?markup=%3C!doctype%20html%3E%3Cbody%3Etarget#target');
const d = f.contentDocument, w = f.contentWindow;
if (mode !== 'blank-open') w.history.replaceState({kept: true, map: new w.Map([['key', 'value']])}, '');
const before = snapshot(d,w);
const base = document.createElement('base');
if (mode === 'base-open') {base.href = '/different-base/'; document.head.append(base);}
let returned;
if (mode === 'loaded-write') {d.write('<p>replacement</p>');}
else if (mode === 'borrowed-open') {returned = Document.prototype.open.call(d);}
else if (mode === 'self-open') {
await new Promise(resolve => {
w.done = result => {returned = result; resolve();};
w.setTimeout(w.Function('done(document.open())'), 0);
});
} else {returned = d.open();}
const after = snapshot(d,w);
const expectedURL = mode === 'self-open' ? before.url : parentURL.href;
for (const field of ['url','uri','location']) check(mode + ':' + field, after[field], expectedURL);
check(mode + ':base', after.base, expectedURL);
check(mode + ':history-length', after.length, before.length);
check(mode + ':history-state', after.state, before.state);
if (mode !== 'loaded-write') check(mode + ':identity', returned === d, true);
check(mode + ':state-identity', after.state === before.state, true);
if (mode !== 'blank-open') check(mode + ':structured-state', after.state.map.get('key'), 'value');
observations.push({mode,before,after});
d.close();
if (mode === 'loaded-open') {
w.history.pushState(null, '', new URL('?after-open', expectedURL).href);
await new Promise(resolve => {
w.addEventListener('popstate', resolve, {once: true});
w.history.back();
});
check(mode + ':back-url', d.URL, expectedURL);
check(mode + ':back-location', w.location.href, expectedURL);
check(mode + ':back-structured-state', w.history.state.map.get('key'), 'value');
}
f.remove(); base.remove();
}
for (const mode of ['nested-call', 'timer', 'microtask']) {
const source = await frame('/compat/child-dynamic-markup-document?markup=%3Cbody%3Esource#source-fragment');
const target = await frame('/compat/child-dynamic-markup-document?markup=%3Cbody%3Etarget#target-fragment');
const w = source.contentWindow, d = target.contentDocument;
w.target = d;
const expected = mode === 'nested-call' ? parentURL.href : w.document.URL.split('#')[0];
if (mode === 'nested-call') {
w.Function('target.open()')();
} else {
await new Promise(resolve => {
w.done = resolve;
const action = w.Function('target.open(); done();');
if (mode === 'timer') w.setTimeout(action, 0);
else w.Promise.resolve().then(action);
});
}
const observed = snapshot(d, target.contentWindow);
for (const field of ['url', 'uri', 'base', 'location']) check(mode + ':' + field, observed[field], expected);
d.close(); source.remove(); target.remove();
}
for (const mode of ['removed', 'windowless', 'old-document']) {
const f = await frame();
let d = f.contentDocument;
if (mode === 'removed') f.remove();
if (mode === 'windowless') d = d.implementation.createHTMLDocument('');
if (mode === 'old-document') {
const loaded = new Promise(resolve => f.onload = resolve);
f.src = '/compat/child-dynamic-markup-document?markup=replacement'; await loaded;
}
const before = d.URL;
check(mode + ':identity', d.open() === d, true);
check(mode + ':url', d.URL, before);
d.close(); f.remove();
}
return {checks, failures, observations};
})()
+3
View File
@@ -34,6 +34,9 @@ mod module_document_write;
#[path = "scripts/document_close.rs"]
mod document_close;
#[path = "scripts/document_open_url.rs"]
mod document_open_url;
#[path = "scripts/document_write_insertion.rs"]
mod document_write_insertion;
@@ -0,0 +1,38 @@
use super::*;
async fn check_document_open_urls(script: &str, checks: u64) -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut url = url::Url::parse(&server.url("/compat/child-dynamic-markup-document"))?;
url.query_pairs_mut()
.append_pair("markup", "<!doctype html><body>entry");
url.set_fragment(Some("entry-fragment"));
let mut page = browser.fetch(url.as_str()).await?;
let result = tokio::time::timeout(
Duration::from_secs(10),
page.evaluate_runtime_expression_with_await_async(
&format!("({script}).then(JSON.stringify)"),
true,
),
)
.await??;
let observed: serde_json::Value = serde_json::from_str(
result["value"]
.as_str()
.expect("document.open URL observations"),
)?;
assert_eq!(observed["checks"], checks);
assert_eq!(observed["failures"], serde_json::json!([]));
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn document_open_updates_active_document_urls_from_entry_document() -> Result<()> {
check_document_open_urls(include_str!("../fixtures/document-open-url.js"), 73).await
}
#[tokio::test(flavor = "multi_thread")]
async fn document_open_updates_root_url_from_child_entry_document() -> Result<()> {
check_document_open_urls(include_str!("../fixtures/document-open-root-url.js"), 6).await
}
@@ -408,6 +408,72 @@ async fn set_document_content_preserves_history_length_and_state() {
server.abort();
}
#[tokio::test(flavor = "multi_thread")]
async fn document_open_keeps_reentrant_history_url_in_frame_tree() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let app = axum::Router::new()
.route(
"/history",
axum::routing::get(|| async {
axum::response::Html(
"<!doctype html><body><iframe src='/source#entry'></iframe>",
)
}),
)
.route(
"/source",
axum::routing::get(|| async { axum::response::Html("<!doctype html><body>entry") }),
);
axum::serve(listener, app).await.unwrap();
});
for child_entry in [false, true] {
let mut ctx = TestContext::new();
install_document_content_test_page(&mut ctx, &format!("http://{addr}/history")).await;
ctx.process_async(json!({
"id": 17,
"method": "Runtime.evaluate",
"sessionId": "SID-1",
"params": {
"returnByValue": true,
"awaitPromise": true,
"expression": format!(r#"new Promise(resolve => {{
navigation.addEventListener('currententrychange', () => {{
history.replaceState({{after: true}}, '', '/after-open');
}}, {{once: true}});
const source = {child_entry} ? document.querySelector('iframe').contentWindow : window;
source.finishOpen = resolve;
source.targetDocument = document;
source.setTimeout(source.Function(`
const finish = finishOpen, target = targetDocument;
target.open();
target.write('<!doctype html><body>replacement');
target.close();
finish(target.URL);
`), 0);
}})"#),
},
}))
.await;
wait_until_scheduler_message(
&mut ctx,
"document.open reentrant history response",
|message| message["id"] == json!(17) && message["sessionId"] == json!("SID-1"),
)
.await;
let response = take_response_by_id(&mut ctx, 17);
let expected = json!(format!("http://{addr}/after-open"));
assert_eq!(
response["result"]["result"]["value"], expected,
"{response:?}"
);
assert_eq!(frame_tree(&mut ctx, 18).await["frame"]["url"], expected);
}
server.abort();
}
// Ported from WPT opening-the-input-stream/mutation-observer.window.js and
// verified against Chromium's Page.setDocumentContent path. Unlike a bare
// document.open(), SetContent also exposes the parser's subsequent additions.
@@ -136,6 +136,7 @@ pub(crate) use crypto::{
};
pub(crate) use css_fontface_runtime::{load_font_faces_for_family, rebuild_font_face_set_faces};
pub(crate) use form_navigation::FormNavigationHistory;
pub(crate) use history_mutation::update_history_for_document_open;
pub(crate) use location_navigation::{
LocationNavigationKind, dispatch_top_level_form_navigation_event,
dispatch_top_level_navigation_event_with_source_element, meta_refresh_navigation_kind,
@@ -24,13 +24,68 @@ use super::navigation_result::{
};
use super::navigation_serialize::sync_child_navigation_entry_seed_from_owner;
use super::navigation_window::{
child_browsing_context_handle_for_runtime_owner,
runtime_window_is_global, window_location_for_holder, window_navigation_for_holder,
child_browsing_context_handle_for_runtime_owner, runtime_window_is_global,
window_history_for_holder, window_location_for_holder, window_navigation_for_holder,
};
use super::*;
use crate::webidl;
use moli_page_types::SameDocumentHistoryUpdate;
/// The URL/history update used by document.open() replaces the current entry
/// without running history API argument conversion or firing a navigate event.
/// A missing serialized state preserves both history.state and its snapshot.
pub(crate) fn update_history_for_document_open<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
url: &url::Url,
) {
let Some(location) = window_location_for_holder(scope, window) else {
return;
};
sync_location_object(scope, location, url.as_str());
let Some(history) = window_history_for_holder(scope, window) else {
return;
};
let Some(entries) = history_entries(scope, history) else {
return;
};
let index = history_index(scope, history);
let Some(previous) = entries
.get_index(scope, index)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
else {
return;
};
let navigation_index = navigation_current_entry_index(scope, window).unwrap_or(index);
let key = navigation_entry_key_value(scope, previous)
.unwrap_or_else(|| new_navigation_entry_key().as_str().to_owned());
let state =
clone_history_entry_state(scope, previous).unwrap_or_else(|| v8::null(scope).into());
let state_json = stringify_history_state(scope, state);
let entry = create_navigation_entry(
scope,
window,
url.as_str(),
state_json.as_deref(),
None,
None,
navigation_index,
&new_navigation_entry_id(),
&key,
);
copy_navigation_entry_document_id(scope, previous, entry);
bind_navigation_entry_runtime_owner(scope, entry, window);
set_history_entry_state(scope, entry, state);
let _ = entries.set_index(scope, index, entry.into());
set_history_entries(scope, history, entries);
sync_navigation_current_entry_from_history_entry(scope, window, entry);
sync_child_navigation_entry_seed_from_owner(scope, window);
if let Some(navigation) = window_navigation_for_holder(scope, window) {
dispatch_navigation_currententrychange(scope, navigation, Some(previous), Some("replace"));
dispatch_navigation_entry_dispose(scope, previous);
}
}
struct ParsedHistoryMutationArgs<'s> {
state: v8::Local<'s, v8::Value>,
unused: String,
@@ -299,11 +299,13 @@ impl JsContextHost {
}
}
} else {
let entry_document = host.document_open_entry_document(scope);
let Some(context) = host.begin_child_document_stream_replacement(
scope,
host_ptr,
child_handle,
document_handle,
entry_document,
) else {
return;
};
@@ -422,6 +424,7 @@ impl JsContextHost {
host_ptr: *mut JsContextHost,
child_handle: DomHandle,
document_handle: DomHandle,
entry_document: Option<DomHandle>,
) -> Option<v8::Local<'s, v8::Context>> {
debug_assert!(std::ptr::eq(host_ptr, self));
if self.child_browsing_context_document_handle(child_handle) != Some(document_handle) {
@@ -483,8 +486,24 @@ impl JsContextHost {
{
return None;
}
let document_url = self.document_url_for_handle(document_handle);
let document_base_url = self.document_base_url_for_handle(document_handle);
let previous_url = self.document_url_for_handle(document_handle);
let replacement_url = entry_document
.filter(|_| self.child_browsing_context_is_live(child_handle))
.map(|entry| self.document_open_replacement_url(document_handle, entry));
let document_url = replacement_url.as_ref().unwrap_or(&previous_url).clone();
let document_base_url = if replacement_url.is_some() {
if document_url == previous_url {
self.dom_host()
.node(document_handle)?
.as_document()?
.fallback_base_url()
.clone()
} else {
document_url.clone()
}
} else {
self.document_base_url_for_handle(document_handle)
};
let replacement_plan = self
.frame_owner_store
.plan_child_document_open_replacement(
@@ -532,6 +551,9 @@ impl JsContextHost {
let _ =
remove_child_to_current_reaction_queue(scope, host_ptr, document_handle, child);
}
if replacement_url.is_some() {
host.set_dom_document_url_for_handle(document_handle, document_url.clone());
}
host.cancel_child_meta_refresh_navigation(child_handle);
host.cancel_stylesheet_subresource_fetches_for_document_owner(retired_owner);
@@ -559,7 +581,7 @@ impl JsContextHost {
child_handle,
current_owner.document_owner(),
document_handle,
document_url,
document_url.clone(),
);
host.note_child_frame_load_started_for_parent(child_handle);
host.queue_child_frame_document_opened_event(child_handle);
@@ -572,6 +594,14 @@ impl JsContextHost {
"opened child document stream through same-LocalWindow owner transaction"
);
});
if replacement_url.is_some() {
let window = script_context.global(scope);
crate::context_bootstrap::update_history_for_document_open(
scope,
window,
&document_url,
);
}
Some(script_context)
}
@@ -593,6 +623,7 @@ impl JsContextHost {
host_ptr,
child_handle,
document_handle,
None,
) else {
return false;
};
@@ -172,8 +172,9 @@ fn node_document_write_or_writeln_callback<'s>(
return;
}
if implicit_replacement_session {
let entry_document = runtime.document_open_entry_document(scope);
clear_window_event_handlers(scope);
runtime.prepare_root_document_replacement(scope, runtime_ptr, handle);
runtime.prepare_root_document_replacement(scope, runtime_ptr, handle, entry_document);
}
let _ = runtime.write_html(scope, runtime_ptr, handle, &html);
rv.set_undefined();
@@ -274,11 +275,13 @@ pub(in crate::native_bridge) fn node_document_open_callback<'s>(
if let Some(child_handle) =
unsafe { &*runtime_ptr }.child_browsing_context_host_for_document_handle(handle)
{
let entry_document = unsafe { &*runtime_ptr }.document_open_entry_document(scope);
let _ = unsafe { &mut *runtime_ptr }.begin_child_document_stream_replacement(
scope,
runtime_ptr,
child_handle,
handle,
entry_document,
);
rv.set(args.this().into());
return;
@@ -294,8 +297,9 @@ pub(in crate::native_bridge) fn node_document_open_callback<'s>(
}
let runtime = unsafe { &mut *runtime_ptr };
if !runtime.has_active_parser_write_insertion_point() {
let entry_document = runtime.document_open_entry_document(scope);
clear_window_event_handlers(scope);
runtime.prepare_root_document_replacement(scope, runtime_ptr, handle);
runtime.prepare_root_document_replacement(scope, runtime_ptr, handle, entry_document);
}
}
rv.set(args.this().into());
@@ -310,6 +314,47 @@ fn clear_window_event_handlers(scope: &mut v8::PinScope<'_, '_>) {
}
impl JsContextHost {
pub(in crate::native_bridge) fn document_open_entry_document(
&self,
scope: &mut v8::PinScope<'_, '_>,
) -> Option<DomHandle> {
if let Some(popup_id) = crate::native_bridge::active_lightweight_popup_id(scope) {
return self.lightweight_popup_document_handle(popup_id);
}
// Borrowed methods execute in their callee realm. HTML instead uses
// the Window that entered this script or microtask.
let context = scope.get_entered_or_microtask_context();
let host_ptr = crate::util::context_host_ptr_from_context_slot(context)?;
if !std::ptr::eq(host_ptr, self) {
return None;
}
let window = context.global(scope);
if let Some(document) = crate::util::get_private_value(
scope,
window,
crate::context_bootstrap::WINDOW_DOCUMENT_SLOT,
)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
{
let (document_host, handle) =
node_runtime_and_handle_from_object_or_detached(scope, document).ok()?;
return std::ptr::eq(document_host, self).then_some(handle);
}
Some(self.document_handle())
}
pub(in crate::native_bridge) fn document_open_replacement_url(
&self,
document: DomHandle,
entry_document: DomHandle,
) -> url::Url {
let mut url = self.document_url_for_handle(entry_document);
if document != entry_document {
url.set_fragment(None);
}
url
}
fn prepare_windowless_document_replacement(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
@@ -329,6 +374,7 @@ impl JsContextHost {
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut JsContextHost,
document_handle: DomHandle,
entry_document: Option<DomHandle>,
) {
self.dispatch_document_open_descendant_frame_unload_lifecycle(scope, document_handle);
self.clear_event_callbacks_for_document_replacement(document_handle, true);
@@ -339,7 +385,30 @@ impl JsContextHost {
document_handle,
);
});
let replacement_url =
entry_document.map(|entry| self.document_open_replacement_url(document_handle, entry));
let url_changed = replacement_url
.as_ref()
.is_some_and(|url| url != self.document_url());
if let Some(url) = replacement_url.as_ref() {
self.set_document_url(url.clone());
}
self.open_root_document(scope);
if let Some(url) = replacement_url
&& let Some(window) =
super::document_associated_window_for_handle(scope, host_ptr, document_handle)
{
// Entry-change listeners can synchronously change the URL again.
// Publish this change first so their later handoffs remain last.
if url_changed {
self.record_same_document_navigation(
&url,
"historyApi",
moli_page_types::SameDocumentHistoryUpdate::Replace,
);
}
crate::context_bootstrap::update_history_for_document_open(scope, window, &url);
}
}
/// Replaces the active root document through the native document stream.
@@ -355,7 +424,7 @@ impl JsContextHost {
) {
let document_handle = self.document_handle();
clear_window_event_handlers(scope);
self.prepare_root_document_replacement(scope, host_ptr, document_handle);
self.prepare_root_document_replacement(scope, host_ptr, document_handle, None);
let _ = self.write_html(scope, host_ptr, document_handle, html);
self.close_document(scope, host_ptr);
}
@@ -9823,7 +9823,7 @@ fn child_frame_target_selector_invalidation_uses_child_document_world() {
</body>
`);
childDocument.close();
childWindow.history.replaceState(null, '', 'about:blank#old');
childWindow.history.replaceState(null, '', '#old');
globalThis.__childTargetFrame = frame;
globalThis.__childTargetOldStyle =
childWindow.getComputedStyle(childDocument.getElementById('old'));
@@ -9855,7 +9855,7 @@ fn child_frame_target_selector_invalidation_uses_child_document_world() {
r#"
(() => {
const childWindow = globalThis.__childTargetFrame.contentWindow;
childWindow.history.replaceState(null, '', 'about:blank#new');
childWindow.history.replaceState(null, '', '#new');
const result = [
globalThis.__childTargetOldStyle.color,
globalThis.__childTargetNewStyle.color