mirror of
https://github.com/lexmount/moli.git
synced 2026-11-03 16:07:36 +00:00
fix(dom): clear defaultView after browsing context destruction
This commit is contained in:
@@ -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;
|
||||
})()
|
||||
@@ -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(),
|
||||
|
||||
+8
-4
@@ -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)
|
||||
}
|
||||
|
||||
-52
@@ -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(
|
||||
|
||||
+54
@@ -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/");
|
||||
|
||||
Reference in New Issue
Block a user