diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index 0db150bc34..da1812836c 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -1450,18 +1450,25 @@ fn current_script_belongs_to_document( .is_some_and(|owner_document| owner_document == document_handle) } -fn document_is_hidden<'s>( - scope: &mut v8::PinScope<'s, '_>, - runtime_ptr: *mut JsContextHost, - handle: DomHandle, - document: v8::Local<'s, v8::Object>, -) -> bool { - unsafe { &*runtime_ptr } +fn document_has_browsing_context(runtime: &JsContextHost, handle: DomHandle) -> bool { + // Detached iframe compatibility windows can become a document's defaultView + // without registering a browsing context. They must not make it visible. + runtime.dom_host().document_handle() == handle + || runtime + .child_browsing_context_host_for_document_handle(handle) + .is_some() + || runtime + .lightweight_popup_id_for_document_handle(handle) + .is_some() +} + +fn document_is_hidden(runtime: &JsContextHost, handle: DomHandle) -> bool { + runtime .dom_host() .node(handle) .and_then(Node::as_document) - .is_some_and(|document| document.visibility_hidden()) - || document_associated_window_for_object(scope, runtime_ptr, handle, document).is_none() + .is_none_or(|document| document.visibility_hidden()) + || !document_has_browsing_context(runtime, handle) } fn document_hidden_getter_function<'s>( @@ -1474,7 +1481,7 @@ fn document_hidden_getter_function<'s>( rv.set_undefined(); return; }; - rv.set_bool(document_is_hidden(scope, runtime_ptr, handle, args.this())); + rv.set_bool(document_is_hidden(unsafe { &*runtime_ptr }, handle)); } fn document_visibility_state_getter_function<'s>( @@ -1487,7 +1494,7 @@ fn document_visibility_state_getter_function<'s>( rv.set_undefined(); return; }; - let state = if document_is_hidden(scope, runtime_ptr, handle, args.this()) { + let state = if document_is_hidden(unsafe { &*runtime_ptr }, handle) { "hidden" } else { "visible" diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs index 8af3f0bf9b..2254930b58 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs @@ -3474,6 +3474,58 @@ fn detached_document_view_uses_document_prototype_accessors() { ); } +#[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("", "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 === 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 detached_document_state_and_collections_use_document_prototype_accessors() { let mut vm = new_storage_test_vm("https://detached-document-prototype-state.test/"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs index 40f0eaf252..f898364fd6 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs @@ -52,6 +52,65 @@ fn input_show_picker_enforces_brand_without_rejecting_inherited_child_origin() { ); } +#[test] +fn document_visibility_and_default_view_follow_receiver_across_realms() { + let mut vm = new_storage_test_vm("https://document-visibility-realms.test/"); + + vm.eval( + r#" +(() => { + const frame = document.createElement("iframe"); + frame.id = "visibility-live-frame"; + (document.body || document.documentElement || document).appendChild(frame); +})() +"#, + ) + .expect("visibility child frame should be created"); + materialize_single_child_default_realm_for_test( + &mut vm, + "document visibility and defaultView child Realm", + ); + + let result = vm + .eval( + r#" +(() => { + const errors = []; + const check = (condition, message) => { if (!condition) errors.push(message); }; + const childWindow = document.getElementById("visibility-live-frame").contentWindow; + const child = childWindow.document; + const parentView = Object.getOwnPropertyDescriptor(Document.prototype, "defaultView").get; + const childView = Object.getOwnPropertyDescriptor(childWindow.Document.prototype, "defaultView").get; + const parentHidden = Object.getOwnPropertyDescriptor(Document.prototype, "hidden").get; + const childHidden = Object.getOwnPropertyDescriptor(childWindow.Document.prototype, "hidden").get; + const parentVisibility = Object.getOwnPropertyDescriptor(Document.prototype, "visibilityState").get; + const childVisibility = Object.getOwnPropertyDescriptor(childWindow.Document.prototype, "visibilityState").get; + check(parentView.call(document) === window, "parent defaultView"); + check(parentView.call(child) === childWindow, "parent getter on child defaultView"); + check(childView.call(document) === window, "child getter on parent defaultView"); + check(childView.call(child) === childWindow, "child defaultView"); + check(!parentHidden.call(child) && parentVisibility.call(child) === "visible", "child is visible through parent getters"); + check(!childHidden.call(document) && childVisibility.call(document) === "visible", "parent is visible through child getters"); + const windowless = child.implementation.createHTMLDocument(""); + check(childView.call(windowless) === null, "windowless defaultView through child getter"); + check(parentHidden.call(windowless) && parentVisibility.call(windowless) === "hidden", "windowless remains hidden through parent getters"); + const popup = window.open("about:blank", "visibility-popup"); + check(popup !== null, "popup created"); + try { + check(!popup.document.hidden && popup.document.visibilityState === "visible", "live popup is visible"); + check(parentView.call(popup.document) === popup, "popup defaultView through parent getter"); + } finally { + popup.close(); + } + return JSON.stringify(errors); +})() +"#, + ) + .expect("cross-realm document visibility and view probe should evaluate"); + + assert_eq!(result, "[]"); +} + #[test] fn cross_realm_dom_bindings_reject_incompatible_receivers_in_their_own_realm() { let mut vm = new_storage_test_vm("https://cross-realm-dom-receivers.test/");