diff --git a/moli-core/tests/fixtures/document-default-view.js b/moli-core/tests/fixtures/document-default-view.js new file mode 100644 index 0000000000..a487fd4744 --- /dev/null +++ b/moli-core/tests/fixtures/document-default-view.js @@ -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('
text
', '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('replacement
'); 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; +})() diff --git a/moli-core/tests/scripts.rs b/moli-core/tests/scripts.rs index 9725967cb5..88372b4522 100644 --- a/moli-core/tests/scripts.rs +++ b/moli-core/tests/scripts.rs @@ -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; diff --git a/moli-core/tests/scripts/document_default_view.rs b/moli-core/tests/scripts/document_default_view.rs new file mode 100644 index 0000000000..7c45bb5df1 --- /dev/null +++ b/moli-core/tests/scripts/document_default_view.rs @@ -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(()) +} diff --git a/moli-renderer-v8/src/native_bridge/context_host/activity.rs b/moli-renderer-v8/src/native_bridge/context_host/activity.rs index f17482dcc0..b79454254e 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/activity.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/activity.rs @@ -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) diff --git a/moli-renderer-v8/src/native_bridge/context_host/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/mod.rs index 85df005c15..f19ad39206 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -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 { diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index d8f91a2ece..feed83b5f1 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -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(), diff --git a/moli-renderer-v8/src/native_bridge/document/detached_install/accessors/iframe_window.rs b/moli-renderer-v8/src/native_bridge/document/detached_install/accessors/iframe_window.rs index ebdf83da54..24849396e1 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_install/accessors/iframe_window.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_install/accessors/iframe_window.rs @@ -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>, ) -> Optionhello
"; - 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 = "nested
"; - 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( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached/tables_and_document_surfaces.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached/tables_and_document_surfaces.rs index 5249c8380d..8cb6f9c490 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached/tables_and_document_surfaces.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached/tables_and_document_surfaces.rs @@ -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("", "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 = "hello
"; + 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 = "nested
"; + 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, "[]"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document/document_bindings.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document/document_bindings.rs index c834dce1c4..e4eb636415 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document/document_bindings.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document/document_bindings.rs @@ -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/");