fix(layout): refresh realtime iframe viewport queries

This commit is contained in:
ldm0
2026-10-14 23:20:59 +08:00
parent 47b66a1948
commit 387e754ae6
7 changed files with 216 additions and 23 deletions
@@ -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 };
@@ -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;
@@ -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,
};
@@ -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,
@@ -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);
@@ -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/", "<!doctype html><body>");
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
@@ -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('<!doctype html><html style="overflow:hidden"><body style="margin:0"></body>');
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('<!doctype html><html style="overflow:hidden"><body style="margin:0"></body>');
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};
})()