fix(dom): clear defaultView after browsing context destruction

This commit is contained in:
ldm0
2026-10-11 18:06:40 +08:00
parent 768d9b29b4
commit 7b4168c2d2
10 changed files with 225 additions and 65 deletions
+99
View File
@@ -0,0 +1,99 @@
(async () => {
const result = {checks: 0, failures: [], observations: []};
const check = (name, action, expected = true) => {
result.checks++;
try {
const actual = action();
if (actual !== expected) result.failures.push({name, actual, expected});
} catch (error) { result.failures.push({name, error: error.name, message: error.message}); }
};
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
const url = name => '/compat/child-dynamic-markup-document?markup=' + name;
const frame = async (owner, name) => {
const node = owner.createElement('iframe');
const loaded = new Promise(resolve => node.onload = resolve);
node.src = url(name);
owner.body.append(node);
await loaded;
return node;
};
const getter = Object.getOwnPropertyDescriptor(Document.prototype, 'defaultView').get;
const popup = window.open('about:blank', 'default-view-probe');
if (!popup) {result.failures.push({name: 'popup-open', message: 'popup was blocked'}); return result;}
const popupDoc = popup.document;
check('popup-live', () => popupDoc.defaultView === popup);
check('root', () => document.defaultView === window);
for (const [label, doc] of [
['constructor', new Document()],
['html-document', document.implementation.createHTMLDocument('')],
['xml-document', document.implementation.createDocument(null, 'root')],
['parser', new DOMParser().parseFromString('<p>text</p>', 'text/html')],
['clone', document.cloneNode(false)],
]) check(label, () => doc.defaultView === null);
const blank = document.createElement('iframe');
document.body.append(blank);
const blankDoc = blank.contentDocument, blankForms = blankDoc.forms;
const blankWin = blank.contentWindow;
check('initial-blank-view', () => blankDoc.defaultView === blankWin);
check('initial-blank-collection-realm', () => Object.getPrototypeOf(blankForms) === blankWin.HTMLCollection.prototype);
blank.remove();
check('initial-blank-removed-null', () => blankDoc.defaultView === null);
for (const mode of ['remove', 'parent-navigation', 'parent-open', 'self-navigation', 'reinsert']) {
const parentFrame = await frame(document, 'parent');
const child = await frame(parentFrame.contentDocument, 'child');
const doc = child.contentDocument, win = child.contentWindow;
const childGetter = Object.getOwnPropertyDescriptor(win.Document.prototype, 'defaultView').get;
const collectionPrototype = win.HTMLCollection.prototype;
const elementPrototype = win.HTMLDivElement.prototype;
check(mode + ':live', () => doc.defaultView === win);
check(mode + ':borrowed-live', () => getter.call(doc) === win);
check(mode + ':child-getter-root', () => childGetter.call(document) === window);
if (mode === 'remove' || mode === 'reinsert') {
child.remove();
check(mode + ':synchronous-null', () => doc.defaultView === null);
if (mode === 'reinsert') {
const loaded = new Promise(resolve => child.onload = resolve);
child.src = url('replacement'); parentFrame.contentDocument.body.append(child);
await loaded;
}
} else if (mode === 'parent-open') {
parentFrame.contentDocument.open(); parentFrame.contentDocument.close();
} else {
const target = mode === 'self-navigation' ? child : parentFrame;
const loaded = new Promise(resolve => target.onload = resolve);
target.src = url('replacement'); await loaded;
}
await tick();
check(mode + ':discarded-null', () => doc.defaultView === null);
check(mode + ':borrowed-null', () => getter.call(doc) === null);
check(mode + ':old-realm-getter-null', () => childGetter.call(doc) === null);
check(mode + ':old-realm-getter-root', () => childGetter.call(document) === window);
check(mode + ':collection-realm', () => Object.getPrototypeOf(doc.forms) === collectionPrototype);
check(mode + ':element-realm', () => Object.getPrototypeOf(doc.createElement('div')) === elementPrototype);
check(mode + ':open-identity', () => doc.open() === doc);
check(mode + ':open-remains-null', () => doc.defaultView === null);
doc.close();
if (mode === 'self-navigation' || mode === 'reinsert') {
check(mode + ':new-document', () => child.contentDocument !== doc);
check(mode + ':new-defaultView', () => child.contentDocument.defaultView === child.contentWindow);
}
parentFrame.remove();
}
const live = await frame(document, 'stream');
const liveDoc = live.contentDocument, liveWin = live.contentWindow;
liveDoc.open(); liveDoc.write('<p>replacement</p>'); liveDoc.close();
check('document-open-keeps-view', () => liveDoc.defaultView === liveWin);
live.remove(); await tick();
check('document-open-removed-null', () => liveDoc.defaultView === null);
popup.close();
// Closing a top-level browsing context is asynchronous, even after closed becomes true.
for (let i = 0; i < 100 && (!popup.closed || popupDoc.defaultView !== null); i++) {
await new Promise(resolve => setTimeout(resolve, 10));
}
check('popup-closed', () => popup.closed);
check('popup-discarded-null', () => popupDoc.defaultView === null);
return result;
})()
+3
View File
@@ -34,6 +34,9 @@ mod document_close;
#[path = "scripts/document_open_url.rs"]
mod document_open_url;
#[path = "scripts/document_default_view.rs"]
mod document_default_view;
#[path = "scripts/retained_child_window.rs"]
mod retained_child_window;
@@ -0,0 +1,29 @@
use super::*;
#[tokio::test(flavor = "multi_thread")]
async fn document_default_view_tracks_browsing_context_without_retargeting_its_realm() -> Result<()>
{
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&server.url("/compat/child-dynamic-markup-document?markup=entry"))
.await?;
let script = include_str!("../fixtures/document-default-view.js");
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 defaultView observations"),
)?;
assert_eq!(observed["checks"], 75);
assert_eq!(observed["failures"], serde_json::json!([]), "{observed}");
server.shutdown().await;
Ok(())
}
@@ -3,8 +3,11 @@ use super::*;
impl JsContextHost {
pub(crate) fn document_has_browsing_context(&self, document: DomHandle) -> bool {
// A detached iframe compatibility window can be a document's defaultView
// without registering a browsing context. Check the native association.
if self.page_context_resources_closed() {
return false;
}
// Retained or synthetic Window associations do not establish a live
// browsing context. Consult the actual document owners instead.
self.dom_host().document_handle() == document
|| self
.child_browsing_context_host_for_document_handle(document)
@@ -1396,6 +1396,10 @@ impl JsContextHost {
self.deferred_context_host_release_queue = queue;
}
pub(crate) fn page_context_resources_closed(&self) -> bool {
self.page_context_resources_closed
}
pub(crate) fn close_page_context_resources_for_teardown(&mut self) {
self.mark_page_context_detached();
if self.page_context_resources_closed {
@@ -16,7 +16,7 @@ use super::super::{
};
use super::element::{canonical_dir_value, element_attribute, set_reflected_attribute};
use super::node::{
node_is_document, node_runtime_and_handle_from_object,
node_is_document, node_relevant_context, node_runtime_and_handle_from_object,
node_runtime_and_handle_from_object_or_detached,
};
use super::{
@@ -1568,12 +1568,10 @@ fn document_html_collection_getter<'s>(
);
return;
};
// A child Document wrapper can precede its Window realm. Follow the
// associated Window once available, including through a borrowed getter.
let context = document_associated_window_for_object(scope, runtime_ptr, handle, receiver)
.and_then(|window| window.get_creation_context(scope))
.or_else(|| receiver.get_creation_context(scope))
.expect("Document must have a creation context");
// A WindowProxy can already target a newer realm after navigation. The
// collection still belongs to the retained Document's own realm.
let context =
node_relevant_context(scope, receiver).expect("Document must have a creation context");
let scope = &mut v8::ContextScope::new(scope, context);
let collection = collections::build_live_collection_for_node(
scope,
@@ -1598,6 +1596,10 @@ fn document_default_view_getter_function<'s>(
rv.set_null();
return;
};
if !document_has_browsing_context(unsafe { &*runtime_ptr }, handle) {
rv.set_null();
return;
}
match document_associated_window_for_object(scope, runtime_ptr, handle, args.this()) {
Some(window) => rv.set(window.into()),
None => rv.set_null(),
@@ -3,7 +3,10 @@ use crate::web_api_interfaces;
use moli_webapi_declare::WebApiObject;
use url::Url;
use crate::native_bridge::document::set_document_associated_window;
use crate::native_bridge::document::{
document_associated_window_for_object, set_document_associated_window,
};
use crate::native_bridge::node_runtime_and_handle_from_object_or_detached;
use super::super::super::detached_owner_document_object;
use super::iframe_style::install_detached_iframe_get_computed_style;
@@ -57,7 +60,8 @@ fn detached_iframe_parent_window<'s>(
scope: &mut v8::PinScope<'s, '_>,
iframe: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
detached_owner_document_object(scope, iframe)?
.get(scope, v8str(scope, "defaultView").into())
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
let document = detached_owner_document_object(scope, iframe)?;
let (runtime_ptr, handle) =
node_runtime_and_handle_from_object_or_detached(scope, document).ok()?;
document_associated_window_for_object(scope, runtime_ptr, handle, document)
}
@@ -741,58 +741,6 @@ fn domparser_xml_preserves_content_type_and_document_interface_for_success_and_e
);
}
#[test]
fn detached_iframe_windows_do_not_change_document_visibility() {
let mut vm = new_storage_test_vm("https://detached-iframe-visibility.test/");
let result = vm
.eval(
r#"
(() => {
const errors = [];
const check = (condition, message) => { if (!condition) errors.push(message); };
const factories = [
["createHTMLDocument", () => document.implementation.createHTMLDocument("")],
["DOMParser", () => new DOMParser().parseFromString("<body></body>", "text/html")],
["createDocument", () => document.implementation.createDocument("urn:test", "root", null)]
];
for (const [name, create] of factories) {
for (const first of ["document", "window"]) {
const outer = create();
const frame = outer.createElementNS("http://www.w3.org/1999/xhtml", "iframe");
frame.srcdoc = "<p>hello</p>";
outer.documentElement.appendChild(frame);
const label = `${name}/${first}`;
check(outer.defaultView === null, `${label}: windowless owner`);
let child;
if (first === "document") {
child = frame.contentDocument;
check(child.hidden && child.visibilityState === "hidden", `${label}: before contentWindow`);
}
const view = frame.contentWindow;
child ||= frame.contentDocument;
check(view !== null && view.document === child, `${label}: synthetic window`);
check(child.defaultView === view, `${label}: associated window retained`);
check(child.hidden && child.visibilityState === "hidden", `${label}: after contentWindow`);
check(outer.hidden && outer.visibilityState === "hidden", `${label}: owner stays hidden`);
const nested = child.createElement("iframe");
nested.srcdoc = "<p>nested</p>";
child.body.appendChild(nested);
const nestedChild = nested.contentDocument;
check(nestedChild.hidden && nestedChild.visibilityState === "hidden", `${label}: nested before contentWindow`);
check(nested.contentWindow.document === nestedChild, `${label}: nested window`);
check(nestedChild.hidden && nestedChild.visibilityState === "hidden", `${label}: nested after contentWindow`);
}
}
return JSON.stringify(errors);
})()
"#,
)
.expect("detached iframe visibility probe should evaluate");
assert_eq!(result, "[]");
}
#[test]
fn body_legacy_colors_treat_null_as_empty_before_reflection() {
let mut vm = new_parsed_test_vm(
@@ -1639,3 +1639,57 @@ fn table_legacy_dom_string_reflectors_use_owner_prototype() {
assert_eq!(result, "ok");
}
#[test]
fn detached_iframe_windows_do_not_create_document_browsing_contexts() {
let mut vm = new_storage_test_vm("https://detached-iframe-visibility.test/");
let result = vm
.eval(
r#"
(() => {
const errors = [];
const check = (condition, message) => { if (!condition) errors.push(message); };
const factories = [
["createHTMLDocument", () => document.implementation.createHTMLDocument("")],
["DOMParser", () => new DOMParser().parseFromString("<body></body>", "text/html")],
["createDocument", () => document.implementation.createDocument("urn:test", "root", null)]
];
for (const [name, create] of factories) {
for (const first of ["document", "window"]) {
const outer = create();
const frame = outer.createElementNS("http://www.w3.org/1999/xhtml", "iframe");
frame.srcdoc = "<p>hello</p>";
outer.documentElement.appendChild(frame);
const label = `${name}/${first}`;
check(outer.defaultView === null, `${label}: windowless owner`);
let child;
if (first === "document") {
child = frame.contentDocument;
check(child.hidden && child.visibilityState === "hidden", `${label}: before contentWindow`);
}
const view = frame.contentWindow;
child ||= frame.contentDocument;
check(view !== null && view.document === child, `${label}: synthetic window`);
check(child.defaultView === null, `${label}: synthetic window has no browsing context`);
check(child.hidden && child.visibilityState === "hidden", `${label}: after contentWindow`);
check(outer.hidden && outer.visibilityState === "hidden", `${label}: owner stays hidden`);
const nested = child.createElement("iframe");
nested.srcdoc = "<p>nested</p>";
child.body.appendChild(nested);
const nestedChild = nested.contentDocument;
check(nestedChild.hidden && nestedChild.visibilityState === "hidden", `${label}: nested before contentWindow`);
check(nested.contentWindow.document === nestedChild, `${label}: nested window`);
check(nested.contentWindow.parent === view, `${label}: nested compatibility parent`);
check(nestedChild.defaultView === null, `${label}: nested synthetic window has no browsing context`);
check(nestedChild.hidden && nestedChild.visibilityState === "hidden", `${label}: nested after contentWindow`);
}
}
return JSON.stringify(errors);
})()
"#,
)
.expect("detached iframe visibility probe should evaluate");
assert_eq!(result, "[]");
}
@@ -1,5 +1,19 @@
use super::*;
#[test]
fn document_default_view_is_null_after_main_context_teardown() {
let mut vm = new_storage_test_vm("https://document-default-view-teardown.test/");
vm.eval("globalThis.retainedDocument = document;").unwrap();
vm._context_host
.borrow_mut()
.close_page_context_resources_for_teardown();
assert_eq!(
vm.eval("String(retainedDocument.defaultView === null)")
.expect("retained Document should remain readable after context teardown"),
"true"
);
}
#[test]
fn input_show_picker_enforces_brand_without_rejecting_inherited_child_origin() {
let mut vm = new_storage_test_vm("https://show-picker-origin.test/");