diff --git a/moli-renderer-v8/src/context_bootstrap/media_queries/evaluation.rs b/moli-renderer-v8/src/context_bootstrap/media_queries/evaluation.rs index 8a665d3d61..b3d62b8ea6 100644 --- a/moli-renderer-v8/src/context_bootstrap/media_queries/evaluation.rs +++ b/moli-renderer-v8/src/context_bootstrap/media_queries/evaluation.rs @@ -5,6 +5,9 @@ use super::{ }; use crate::context_bootstrap::DEFAULT_WINDOW_SURFACE_PROFILE; use crate::context_bootstrap::current_window_style_viewport; +use crate::context_bootstrap::window_accessors::{ + prepare_window_viewport_read, window_current_dispatch_scope, +}; use crate::context_bootstrap::{ MEDIA_QUERY_LIST_LISTENERS_SLOT, MEDIA_QUERY_LIST_MATCHES_SLOT, MEDIA_QUERY_LIST_MEDIA_SLOT, MEDIA_QUERY_LIST_ONCHANGE_SLOT, MEDIA_QUERY_LIST_REGISTRY_SLOT, @@ -95,8 +98,18 @@ fn media_query_list_attribute_getter_callback<'s>( .unwrap_or_else(|| scope.get_current_context()); let scope = &mut v8::ContextScope::new(scope, context); let media = media_query_list_media_slot(scope, args.this()).unwrap_or_default(); + let global = context.global(scope); let matches = if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { let host = unsafe { &*host_ptr }; + // A retained list must not observe the replacement document that + // now occupies the same iframe element's browsing context slot. + if window_current_dispatch_scope(scope, global, host).is_none() { + rv.set_bool(false); + return; + } + if !prepare_window_viewport_read(scope, global) { + return; + } evaluate_match_media_query_list_with_viewport( &media, Some(host.emulated_media()), @@ -164,6 +177,10 @@ pub(crate) fn window_match_media_callback<'s>( return; }; let serialized_query = serialize_match_media_query_list(&parsed.query); + let global = scope.get_current_context().global(scope); + if !prepare_window_viewport_read(scope, global) { + return; + } let (emulated_media, viewport) = if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { let host = unsafe { &*host_ptr }; diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/visual_viewport.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/visual_viewport.rs index cdee656c70..9fe116ef79 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/visual_viewport.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/visual_viewport.rs @@ -134,18 +134,36 @@ fn visual_viewport_attribute_getter_callback<'s>( if matches!( slot, VISUAL_VIEWPORT_WIDTH_SLOT | VISUAL_VIEWPORT_HEIGHT_SLOT - ) && let Some(host_ptr) = crate::util::context_host_ptr_from_global_bridge(scope) - { - let viewport = - crate::context_bootstrap::current_window_style_viewport(scope, unsafe { &*host_ptr }); + ) { + // A borrowed getter belongs to its receiver's Window, even when the + // function was obtained from another realm's VisualViewport prototype. + let context = args + .this() + .get_creation_context(scope) + .unwrap_or_else(|| scope.get_current_context()); + let scope = &mut v8::ContextScope::new(scope, context); + let window = context.global(scope); + let current = super::super::window_accessors::window_host_ptr(scope, window).is_some_and( + |host_ptr| { + super::super::window_accessors::window_current_dispatch_scope( + scope, + window, + unsafe { &*host_ptr }, + ) + .is_some() + }, + ); + if !current { + rv.set_double(0.0); + return; + } + if !super::super::window_accessors::prepare_window_viewport_read(scope, window) { + return; + } let value = if slot == VISUAL_VIEWPORT_WIDTH_SLOT { - viewport - .width - .unwrap_or(DEFAULT_WINDOW_SURFACE_PROFILE.inner_width) + super::super::window_accessors::window_inner_surface_width(scope, window) } else { - viewport - .height - .unwrap_or(DEFAULT_WINDOW_SURFACE_PROFILE.inner_height) + super::super::window_accessors::window_inner_surface_height(scope, window) }; rv.set_double(value); return; diff --git a/moli-renderer-v8/src/context_bootstrap/window_accessors.rs b/moli-renderer-v8/src/context_bootstrap/window_accessors.rs index e8da9beca3..afaae6227e 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_accessors.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_accessors.rs @@ -28,21 +28,22 @@ pub(super) use document::{WINDOW_DOCUMENT_SLOT, window_document_getter_template} pub(crate) use document::{ bind_current_child_window_document, retain_window_document_in_retired_realm, }; -pub(super) use helpers::window_child_context_handle; pub(crate) use helpers::{current_window_style_viewport, window_host_ptr}; +pub(super) use helpers::{window_child_context_handle, window_current_dispatch_scope}; pub(super) use interceptors::{ window_indexed_property_descriptor, window_indexed_property_enumerator, window_indexed_property_getter, window_indexed_property_query, window_named_property_getter, window_named_property_query, }; +pub(super) use surface::{ + prepare_window_viewport_read, window_custom_elements_getter, window_device_pixel_ratio_getter, + window_frames_getter, window_inner_height_getter, window_inner_surface_height, + window_inner_surface_width, window_inner_width_getter, window_navigator_getter, + window_opener_setter, window_outer_height_getter, window_outer_width_getter, + window_performance_getter, window_screen_getter, window_scroll_x_getter, + window_scroll_y_getter, window_self_getter, window_speech_synthesis_getter, + window_visual_viewport_getter, window_window_getter, +}; pub(crate) use surface::{ window_closed_getter, window_opener_getter, window_parent_getter, window_top_getter, }; -pub(super) use surface::{ - window_custom_elements_getter, window_device_pixel_ratio_getter, window_frames_getter, - window_inner_height_getter, window_inner_surface_height, window_inner_surface_width, - window_inner_width_getter, window_navigator_getter, window_opener_setter, - window_outer_height_getter, window_outer_width_getter, window_performance_getter, - window_screen_getter, window_scroll_x_getter, window_scroll_y_getter, window_self_getter, - window_speech_synthesis_getter, window_visual_viewport_getter, window_window_getter, -}; diff --git a/moli-renderer-v8/src/context_bootstrap/window_accessors/helpers.rs b/moli-renderer-v8/src/context_bootstrap/window_accessors/helpers.rs index 4a4f9bd8c9..f989558163 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_accessors/helpers.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_accessors/helpers.rs @@ -106,7 +106,7 @@ pub(crate) fn window_host_ptr( .or_else(|| context_host_ptr_from_global_bridge(scope)) } -pub(super) fn window_current_dispatch_scope<'s>( +pub(in crate::context_bootstrap) fn window_current_dispatch_scope<'s>( scope: &mut v8::PinScope<'s, '_>, receiver: v8::Local<'s, v8::Object>, host: &JsContextHost, diff --git a/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs b/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs index dff59a2225..9832e84340 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs @@ -47,7 +47,7 @@ fn window_inner_surface_dimension<'s>( /// A child Window's viewport is produced by its parent layout. Resolve that /// dependency before public reads, without entering any lifecycle callbacks. -fn prepare_window_inner_surface_read<'s>( +pub(in crate::context_bootstrap) fn prepare_window_viewport_read<'s>( scope: &mut v8::PinScope<'s, '_>, receiver: v8::Local<'s, v8::Object>, ) -> bool { @@ -201,7 +201,7 @@ pub(in crate::context_bootstrap) fn window_inner_width_getter<'s>( let Some(receiver) = window_receiver(scope, &args) else { return; }; - if !prepare_window_inner_surface_read(scope, receiver) { + if !prepare_window_viewport_read(scope, receiver) { return; } let width = window_inner_surface_width(scope, receiver); @@ -216,7 +216,7 @@ pub(in crate::context_bootstrap) fn window_inner_height_getter<'s>( let Some(receiver) = window_receiver(scope, &args) else { return; }; - if !prepare_window_inner_surface_read(scope, receiver) { + if !prepare_window_viewport_read(scope, receiver) { return; } let height = window_inner_surface_height(scope, receiver); diff --git a/moli-renderer-v8/src/script_vm/tests/realtime_frames.rs b/moli-renderer-v8/src/script_vm/tests/realtime_frames.rs index 4ba02656e6..bc4f886fcd 100644 --- a/moli-renderer-v8/src/script_vm/tests/realtime_frames.rs +++ b/moli-renderer-v8/src/script_vm/tests/realtime_frames.rs @@ -18,6 +18,98 @@ fn frame_fixture(policy: LayoutPolicy, width: u32, height: u32) -> StandaloneScr vm } +#[test] +fn realtime_frame_viewport_reads_refresh_ancestors_and_use_the_receiver_realm() { + let mut vm = new_parsed_test_vm("https://realtime-frame.test/", ""); + vm.set_layout_policy(LayoutPolicy::Realtime); + // Run the identical synchronous fixture against the release CDP entry and + // Chromium. Neither screenshots nor lifecycle callbacks can refresh a read. + let fixture = include_str!("../../../tests/fixtures/realtime-frame-viewport-reads.js"); + let result = vm.eval(&format!("JSON.stringify({fixture})")).unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["failures"], serde_json::json!([]), "{result}"); + assert_eq!( + vm.layout_pass_observability_for_test() + .3 + .unwrap() + .paint_operation_count, + 0 + ); +} + +#[test] +fn realtime_frame_viewport_reads_share_current_layout_and_propagate_failures() { + for (read, expected) in [ + ("String(query.matches)", "true"), + ("String(child.matchMedia('(width:400px)').matches)", "true"), + ("String(viewport.width)", "400"), + ("String(viewport.height)", "120"), + ] { + let mut vm = frame_fixture(LayoutPolicy::Realtime, 320, 100); + vm.eval("globalThis.viewport=child.visualViewport;globalThis.query=child.matchMedia('(width:400px)');frame.offsetWidth") + .unwrap(); + let before = vm.layout_pass_observability_for_test().1; + vm.eval("frame.style.width='400px';frame.style.height='120px'") + .unwrap(); + vm._context_host + .borrow() + .simulate_active_layout_pass_for_test(true); + let result = vm.eval(read); + vm._context_host + .borrow() + .simulate_active_layout_pass_for_test(false); + let error = result.unwrap_err(); + assert!( + error.to_string().contains("Layout failed:"), + "{read}: {error}" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, before); + assert_eq!(vm.eval(read).unwrap(), expected, "{read}"); + assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); + assert_eq!(vm.eval(read).unwrap(), expected, "{read}"); + assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); + } +} + +#[test] +fn viewport_reads_do_not_add_layout_to_legacy_modes_or_top_level_queries() { + for policy in [ + LayoutPolicy::Mock, + LayoutPolicy::OnDemand, + LayoutPolicy::Realtime, + ] { + let mut vm = frame_fixture(policy, 320, 100); + vm.eval("globalThis.viewport=child.visualViewport;globalThis.query=child.matchMedia('(width:400px)');frame.offsetWidth") + .unwrap(); + let before = vm.layout_pass_observability_for_test().1; + vm.eval("frame.style.width='400px';document.body.style.width='800px'") + .unwrap(); + // Root viewport sizes do not depend on a descendant's box. Reading + // them or a root media query must not unnecessarily flush the tree. + vm.eval("visualViewport.width;visualViewport.height;matchMedia('(min-width:1px)').matches") + .unwrap(); + assert_eq!( + vm.layout_pass_observability_for_test().1, + before, + "{policy:?}" + ); + if policy != LayoutPolicy::Realtime { + vm.eval("viewport.width;viewport.height;query.matches;child.matchMedia('(width:400px)').matches") + .unwrap(); + assert_eq!( + vm.layout_pass_observability_for_test().1, + before, + "{policy:?}" + ); + assert!( + !vm._context_host + .borrow() + .realtime_layout_allocated_for_test() + ); + } + } +} + #[test] fn realtime_layout_zero_sized_frame_preserves_fixed_and_percentage_metrics() { // Chromium 145, child root overflow:hidden: an empty viewport clips diff --git a/moli-renderer-v8/tests/fixtures/realtime-frame-viewport-reads.js b/moli-renderer-v8/tests/fixtures/realtime-frame-viewport-reads.js new file mode 100644 index 0000000000..c1c0ae2c3c --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/realtime-frame-viewport-reads.js @@ -0,0 +1,65 @@ +(() => { + const rows = [], failures = [], callbacks = []; + const check = (name, actual, expected) => { + rows.push([name, actual]); + if (JSON.stringify(actual) !== JSON.stringify(expected)) failures.push(name); + }; + const frame = document.createElement('iframe'); + frame.style.cssText = 'width:320px;height:100px;border:0'; + document.body.append(frame); + const child = frame.contentWindow; + child.document.open(); + child.document.write(''); + child.document.close(); + const viewport = child.visualViewport; + const query = child.matchMedia('(min-width: 400px)'); + query.onchange = () => callbacks.push('media'); + viewport.onresize = () => callbacks.push('viewport'); + requestAnimationFrame(() => callbacks.push('frame')); + // Establish an old canonical layout before each first dirty read. + frame.getBoundingClientRect(); + check('initial', [query.matches, viewport.width, viewport.height], [false, 320, 100]); + frame.style.width = '400px'; + check('retained-query', query.matches, true); + frame.style.width = '420px'; + check('retained-width', viewport.width, 420); + frame.style.height = '140px'; + check('retained-height', viewport.height, 140); + frame.style.width = '380px'; + check('new-query', child.matchMedia('(max-width: 390px)').matches, true); + frame.style.width = '440px'; + const widthGetter = Object.getOwnPropertyDescriptor(VisualViewport.prototype, 'width').get; + check('borrowed-width', widthGetter.call(viewport), 440); + const childWidthGetter = Object.getOwnPropertyDescriptor(child.VisualViewport.prototype, 'width').get; + check('borrowed-root', childWidthGetter.call(visualViewport), visualViewport.width); + const matchesGetter = Object.getOwnPropertyDescriptor(MediaQueryList.prototype, 'matches').get; + frame.style.width = '360px'; + check('borrowed-query', matchesGetter.call(query), false); + const nestedFrame = child.document.createElement('iframe'); + nestedFrame.style.cssText = 'width:50%;height:50px;border:0'; + child.document.body.append(nestedFrame); + const nested = nestedFrame.contentWindow; + nested.document.open(); + nested.document.write(''); + nested.document.close(); + const nestedQuery = nested.matchMedia('(min-width: 200px)'); + const nestedViewport = nested.visualViewport; + frame.getBoundingClientRect(); + frame.style.width = '400px'; + check('nested-query', nestedQuery.matches, true); + frame.style.width = '480px'; + check('nested-width', nestedViewport.width, 240); + check('clean', [nestedViewport.width, query.matches, viewport.width], [240, true, 480]); + frame.style.display = 'none'; + frame.style.width = '540px'; + frame.style.height = '180px'; + check('hidden', [viewport.width, viewport.height, query.matches], [480, 140, true]); + frame.style.display = 'block'; + check('shown', [viewport.width, viewport.height, query.matches], [540, 180, true]); + frame.remove(); + check('removed', [viewport.width, viewport.height, query.matches], [0, 0, false]); + document.body.append(frame); + check('reattached-old-objects', [viewport.width, viewport.height, query.matches], [0, 0, false]); + check('synchronous-callbacks', callbacks, []); + return {rows, failures}; +})()