From 3bb60ad8ea2097ffaadf12b6f271fe45aaa781aa Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 13 Oct 2026 07:12:47 +0800 Subject: [PATCH] fix(renderer): integrate realtime scroll and pre-animation phases --- .../src/owner_ready_task_source.rs | 8 + .../src/native_bridge/context_host/layout.rs | 1 + .../src/native_bridge/context_host/mod.rs | 1 + .../context_host/realtime_events.rs | 287 ++++++++++++++++++ .../context_host/realtime_layout.rs | 3 + .../context_host/realtime_rendering.rs | 11 +- .../context_host/rendering_updates.rs | 14 +- .../context_host/window_document_tasks.rs | 23 ++ .../native_bridge/element/geometry/scroll.rs | 3 + .../rendering_update/source.rs | 4 + .../runtime/page_vm/tests/rendering_update.rs | 1 + .../rendering_update/realtime_environment.rs | 2 +- .../tests/rendering_update/realtime_events.rs | 239 +++++++++++++++ .../script_vm/rendering_update/realtime.rs | 2 +- .../rendering_update/realtime_environment.rs | 71 ++++- .../fixtures/realtime-scroll-document-open.js | 26 ++ .../fixtures/realtime-scroll-driven-pane.js | 28 ++ .../tests/fixtures/realtime-scroll-phases.js | 49 +++ 18 files changed, 758 insertions(+), 15 deletions(-) create mode 100644 moli-renderer-v8/src/native_bridge/context_host/realtime_events.rs create mode 100644 moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_events.rs create mode 100644 moli-renderer-v8/tests/fixtures/realtime-scroll-document-open.js create mode 100644 moli-renderer-v8/tests/fixtures/realtime-scroll-driven-pane.js create mode 100644 moli-renderer-v8/tests/fixtures/realtime-scroll-phases.js diff --git a/moli-owner-queue/src/owner_ready_task_source.rs b/moli-owner-queue/src/owner_ready_task_source.rs index d179cf2cf4..f16f8970f9 100644 --- a/moli-owner-queue/src/owner_ready_task_source.rs +++ b/moli-owner-queue/src/owner_ready_task_source.rs @@ -74,6 +74,12 @@ impl OwnerReadyTaskRoute { &self.shared.signal } + /// Whether the owning consumer still accepts work. This is an admission + /// hint; producers must still handle closure when publishing a payload. + pub fn is_open(&self) -> bool { + lock_state(&self.shared.state).source_open + } + /// Enqueue one task and publish a wake only when the source becomes ready. pub fn send_and_signal_if_newly_ready( &self, @@ -406,8 +412,10 @@ mod tests { fn a_closed_source_rejects_work_without_publishing_a_wake() { let (source, wakes) = counting_source(); let route = source.route(); + assert!(route.is_open()); drop(source); + assert!(!route.is_open()); assert!(route.send_and_signal_if_newly_ready(1).is_err()); assert_eq!(wakes.load(Ordering::Relaxed), 0); } diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs index 3ca834dbc9..2443a04044 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -683,6 +683,7 @@ impl PageScriptHost { } self.prune_realtime_animation_frames(); self.prune_realtime_environment(); + self.prune_realtime_rendering_events(); if self.layout_policy().is_realtime() { crate::observer_runtime::prune_realtime_resize_observers(self); } 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 0cec4902b5..a5fe7ddd6e 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -119,6 +119,7 @@ mod media_loads; mod misc_platform_api_tasks; mod realtime_animation_frames; mod realtime_environment; +mod realtime_events; mod realtime_layout; mod realtime_rendering; pub(crate) use media_loads::PendingMediaLoadTerminalFollowup; diff --git a/moli-renderer-v8/src/native_bridge/context_host/realtime_events.rs b/moli-renderer-v8/src/native_bridge/context_host/realtime_events.rs new file mode 100644 index 0000000000..c974496200 --- /dev/null +++ b/moli-renderer-v8/src/native_bridge/context_host/realtime_events.rs @@ -0,0 +1,287 @@ +use super::{ + JsContextHost, WindowDocumentOwner, WindowDocumentTaskTarget, + rendering_updates::PendingRenderingUpdatePayload, + window_document_tasks::{ExactWindowDocumentTaskLedger, PendingExactWindowDocumentTask}, +}; +use crate::{ + document_runtime::{DomHandle, EventTargetHandle}, + page_task_queue::RendererPageRenderingUpdateTaskKind, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum RealtimeEventPayload { + DocumentScroll { end: bool }, + ElementScroll { element: DomHandle, end: bool }, + PostParseAutofocus, + AnimationStartScan(EventTargetHandle), +} + +impl RealtimeEventPayload { + fn follows_document_open(self) -> bool { + matches!( + self, + Self::DocumentScroll { .. } | Self::ElementScroll { .. } + ) + } +} + +pub(super) type RealtimeRenderingEvents = + ExactWindowDocumentTaskLedger; + +type PendingEvent = + PendingExactWindowDocumentTask; + +/// An owned phase entry. No ledger borrow or page callback crosses the take. +pub(crate) struct PreparedRealtimeRenderingEvent(PendingEvent); + +impl JsContextHost { + pub(super) fn queue_realtime_rendering_update( + &self, + target: WindowDocumentTaskTarget, + kind: RendererPageRenderingUpdateTaskKind, + payload: PendingRenderingUpdatePayload, + ) -> bool { + match payload { + PendingRenderingUpdatePayload::DocumentScrollEvents => self.queue_realtime_events( + target, + kind, + &[ + RealtimeEventPayload::DocumentScroll { end: false }, + RealtimeEventPayload::DocumentScroll { end: true }, + ], + ), + PendingRenderingUpdatePayload::PostParseAutofocus => self.queue_realtime_events( + target, + kind, + &[RealtimeEventPayload::PostParseAutofocus], + ), + PendingRenderingUpdatePayload::AnimationStartScan(target_handle) => self + .queue_realtime_events( + target, + kind, + &[RealtimeEventPayload::AnimationStartScan(target_handle)], + ), + _ => unreachable!("environment notifications have their own Realtime residence"), + } + } + + pub(crate) fn queue_realtime_element_scroll_events( + &self, + scope: &mut v8::PinScope<'_, '_>, + element: DomHandle, + ) { + if self.realtime_layout_state.is_none() { + return; + } + let Some(target) = self.window_document_task_target_for_node(scope, element) else { + return; + }; + self.queue_realtime_events( + target, + RendererPageRenderingUpdateTaskKind::DocumentScrollEvents, + &[ + RealtimeEventPayload::ElementScroll { + element, + end: false, + }, + RealtimeEventPayload::ElementScroll { element, end: true }, + ], + ); + } + + fn queue_realtime_events( + &self, + target: WindowDocumentTaskTarget, + kind: RendererPageRenderingUpdateTaskKind, + payloads: &[RealtimeEventPayload], + ) -> bool { + let Some(state) = &self.realtime_layout_state else { + return false; + }; + if !self.page_rendering_update_sender().is_open() { + state.rendering_events.borrow_mut().retain(|_| false); + return false; + } + { + let mut events = state.rendering_events.borrow_mut(); + for payload in payloads { + if events + .find_slot_index(target, kind, |pending| pending == payload) + .is_none() + { + let id = events.allocate_task_id(|id| id); + events.push(PendingEvent::new(id, target, kind, *payload)); + } + } + } + // The events are semantic demand. Only FrameLifecycle owns a Page + // source entry, so the old immediate FIFO cannot deliver them twice. + self.refresh_realtime_rendering_opportunity(); + true + } + + pub(crate) fn has_realtime_rendering_events(&self) -> bool { + self.realtime_layout_state.as_ref().is_some_and(|state| { + state.rendering_events.borrow().iter().any(|pending| { + let target = pending.target(); + self.window_document_owner_is_current_for_dispatch_scope( + target.owner(), + target.dispatch_scope(), + ) + }) + }) + } + + pub(crate) fn take_realtime_rendering_events( + &self, + target: WindowDocumentTaskTarget, + kind: RendererPageRenderingUpdateTaskKind, + ) -> Vec { + let Some(state) = &self.realtime_layout_state else { + return Vec::new(); + }; + // Like Blink's ScriptedAnimationController::DispatchEvents, remove + // the entire filtered batch before any callback can enqueue more. + state + .rendering_events + .borrow_mut() + .take_where(|pending| pending.target() == target && pending.kind() == kind) + .into_iter() + .map(PreparedRealtimeRenderingEvent) + .collect() + } + + pub(crate) fn restore_realtime_rendering_events( + &self, + target: WindowDocumentTaskTarget, + uncalled: Vec, + ) { + let Some(state) = &self.realtime_layout_state else { + return; + }; + if !self.window_document_owner_is_current_for_dispatch_scope( + target.owner(), + target.dispatch_scope(), + ) || !self.page_rendering_update_sender().is_open() + { + return; + } + let mut events = state.rendering_events.borrow_mut(); + for PreparedRealtimeRenderingEvent(pending) in uncalled.into_iter().rev() { + if pending.target() != target && !pending.payload().follows_document_open() { + continue; + } + // Retain the older uncalled entry's place ahead of newer work. + if let Some(index) = events.find_slot_index(target, pending.kind(), |payload| { + payload == pending.payload() + }) { + events.remove_at(index); + } + events.prepend(PendingEvent::new( + pending.task_id(), + target, + pending.kind(), + pending.into_payload(), + )); + } + } + + pub(crate) fn dispatch_realtime_rendering_event( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + host_ptr: *mut JsContextHost, + target: WindowDocumentTaskTarget, + PreparedRealtimeRenderingEvent(pending): PreparedRealtimeRenderingEvent, + ) -> bool { + // The driver obtains `target` from this running batch's Document + // list. Only the explicit document.open transaction may rebind it. + if !self.window_document_owner_is_current_for_dispatch_scope( + target.owner(), + target.dispatch_scope(), + ) || (pending.target() != target && !pending.payload().follows_document_open()) + { + return false; + } + let (element, end) = match pending.into_payload() { + RealtimeEventPayload::PostParseAutofocus => { + return self.dispatch_authorized_post_parse_autofocus(scope, host_ptr, target); + } + RealtimeEventPayload::AnimationStartScan(event_target) => { + return self.dispatch_authorized_animation_start_scan( + scope, + host_ptr, + target, + event_target, + ); + } + RealtimeEventPayload::DocumentScroll { end } => (None, end), + RealtimeEventPayload::ElementScroll { element, end } => (Some(element), end), + }; + let Some(resolved) = self.resolve_authorized_window_document_task_context(scope, target) + else { + return false; + }; + let scope = &mut v8::ContextScope::new(scope, resolved.context); + let previous_scope = target.dispatch_scope().enter(scope); + let name = if end { "scrollend" } else { "scroll" }; + let dispatched = if let Some(element) = element { + super::super::element::construct_simple_event(scope, name, false, false, false) + .is_some_and(|event| { + self.dispatch_public_event_best_effort( + scope, + host_ptr, + EventTargetHandle::Node(element), + event, + "realtime element scroll", + ) + .is_ok() + }) + } else { + crate::window_host::dispatch_document_event_for_handle( + scope, + host_ptr, + resolved.document_handle, + name, + ) + }; + target.dispatch_scope().restore(scope, previous_scope); + dispatched + } + + pub(super) fn rebind_realtime_rendering_events( + &self, + retired: WindowDocumentOwner, + current: WindowDocumentOwner, + ) { + let Some(state) = &self.realtime_layout_state else { + return; + }; + let mut events = state.rendering_events.borrow_mut(); + for pending in events.take_where(|pending| pending.target().owner() == retired) { + // document.open preserves the Web Document's pending scroll + // events. Parser completion and compatibility animation scans + // belong to the retired content and must not be transferred. + if pending.payload().follows_document_open() { + events.push(PendingEvent::new( + pending.task_id(), + WindowDocumentTaskTarget::new(current, pending.target().dispatch_scope()), + pending.kind(), + pending.into_payload(), + )); + } + } + } + + pub(super) fn prune_realtime_rendering_events(&self) { + let Some(state) = &self.realtime_layout_state else { + return; + }; + state.rendering_events.borrow_mut().retain(|pending| { + let target = pending.target(); + self.window_document_owner_is_current_for_dispatch_scope( + target.owner(), + target.dispatch_scope(), + ) + }); + } +} diff --git a/moli-renderer-v8/src/native_bridge/context_host/realtime_layout.rs b/moli-renderer-v8/src/native_bridge/context_host/realtime_layout.rs index 9ad6578998..54c38b6723 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/realtime_layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/realtime_layout.rs @@ -6,6 +6,7 @@ use moli_layout::{ use super::realtime_animation_frames::RealtimeAnimationFrames; use super::realtime_environment::RealtimeEnvironmentState; +use super::realtime_events::RealtimeRenderingEvents; use super::realtime_rendering::RealtimeFrameState; use super::{OwnerDispatchScope, PageScriptHost, WindowDocumentTaskTarget}; use crate::{document_runtime::DomHandle, style_engine::StyloStyleEnvironment}; @@ -44,6 +45,7 @@ pub(super) struct RealtimeLayoutState { pub(super) frame: RefCell, pub(super) animation_frames: RefCell, pub(super) environment: RefCell, + pub(super) rendering_events: RefCell, } impl Default for RealtimeLayoutState { @@ -54,6 +56,7 @@ impl Default for RealtimeLayoutState { frame: RefCell::new(RealtimeFrameState::default()), animation_frames: RefCell::new(RealtimeAnimationFrames::default()), environment: RefCell::new(RealtimeEnvironmentState::default()), + rendering_events: RefCell::new(RealtimeRenderingEvents::default()), } } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/realtime_rendering.rs b/moli-renderer-v8/src/native_bridge/context_host/realtime_rendering.rs index 9747c33f1e..5da6c114ab 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/realtime_rendering.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/realtime_rendering.rs @@ -83,6 +83,7 @@ impl PageScriptHost { let retired = WindowDocumentOwner::Frame(retired); let current = WindowDocumentOwner::Frame(current); state.environment.borrow_mut().rebind(retired, current); + self.rebind_realtime_rendering_events(retired, current); self.rebind_realtime_animation_frame_document(retired, current); crate::observer_runtime::rebind_realtime_resize_observer_document(self, retired, current); { @@ -116,6 +117,10 @@ impl PageScriptHost { .environment .borrow_mut() .retain(|target| target.owner() != retired); + state + .rendering_events + .borrow_mut() + .retain(|pending| pending.target().owner() != retired); } crate::observer_runtime::retire_realtime_resize_observer_document(self, retired); self.refresh_realtime_rendering_opportunity(); @@ -186,12 +191,14 @@ impl PageScriptHost { let resize_pending = crate::observer_runtime::has_pending_realtime_resize_observers(self, None); let environment_pending = state.environment.borrow().has_pending(); + let events_pending = self.has_realtime_rendering_events(); let mut frame = state.frame.borrow_mut(); if !self.document_activity().visible || (!frame.source_requested && !callbacks_pending && !resize_pending - && !environment_pending) + && !environment_pending + && !events_pending) { frame.pending = None; frame.last_deadline = None; @@ -219,6 +226,7 @@ impl PageScriptHost { { frame.pending = None; frame.last_deadline = None; + state.rendering_events.borrow_mut().retain(|_| false); tracing::debug!( ?target, ?id, @@ -396,6 +404,7 @@ impl PageScriptHost { } self.prune_realtime_animation_frames(); self.prune_realtime_environment(); + self.prune_realtime_rendering_events(); crate::observer_runtime::prune_realtime_resize_observers(self); self.refresh_realtime_rendering_opportunity(); } diff --git a/moli-renderer-v8/src/native_bridge/context_host/rendering_updates.rs b/moli-renderer-v8/src/native_bridge/context_host/rendering_updates.rs index 531d8bb136..99422529a0 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/rendering_updates.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/rendering_updates.rs @@ -214,6 +214,16 @@ impl PageScriptHost { kind: RendererPageRenderingUpdateTaskKind, payload: PendingRenderingUpdatePayload, ) -> bool { + if self.layout_policy().is_realtime() + && matches!( + kind, + RendererPageRenderingUpdateTaskKind::DocumentScrollEvents + | RendererPageRenderingUpdateTaskKind::PostParseAutofocus + | RendererPageRenderingUpdateTaskKind::AnimationStartScan + ) + { + return self.queue_realtime_rendering_update(target, kind, payload); + } if self .rendering_updates .find_slot_index(target, kind, |pending| pending == &payload) @@ -314,7 +324,7 @@ impl PageScriptHost { self.rendering_updates.remove(task_id).is_some() } - fn dispatch_authorized_animation_start_scan( + pub(super) fn dispatch_authorized_animation_start_scan( &mut self, scope: &mut v8::PinScope<'_, '_>, host_ptr: *mut PageScriptHost, @@ -338,7 +348,7 @@ impl PageScriptHost { dispatched } - fn dispatch_authorized_post_parse_autofocus( + pub(super) fn dispatch_authorized_post_parse_autofocus( &mut self, scope: &mut v8::PinScope<'_, '_>, host_ptr: *mut PageScriptHost, diff --git a/moli-renderer-v8/src/native_bridge/context_host/window_document_tasks.rs b/moli-renderer-v8/src/native_bridge/context_host/window_document_tasks.rs index 007d368b83..3543f21c61 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/window_document_tasks.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/window_document_tasks.rs @@ -68,6 +68,29 @@ impl Default for WindowTaskLedger { } impl WindowTaskLedger { + pub(super) fn iter(&self) -> impl Iterator> { + self.pending.iter() + } + + pub(super) fn retain(&mut self, keep: impl FnMut(&PendingWindowTask) -> bool) { + self.pending.retain(keep); + } + + pub(super) fn prepend(&mut self, pending: PendingWindowTask) { + self.pending.insert(0, pending); + } + + pub(super) fn take_where( + &mut self, + mut matches: impl FnMut(&PendingWindowTask) -> bool, + ) -> Vec> { + let (taken, remaining) = std::mem::take(&mut self.pending) + .into_iter() + .partition(|pending| matches(pending)); + self.pending = remaining; + taken + } + pub(super) fn allocate_task_id(&mut self, from_raw: impl FnOnce(u64) -> I) -> I { let raw = self.next_task_id; self.next_task_id = raw.checked_add(1).expect("Window task id overflow"); diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/scroll.rs b/moli-renderer-v8/src/native_bridge/element/geometry/scroll.rs index fdc020728b..c401acf7b1 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/scroll.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/scroll.rs @@ -52,6 +52,9 @@ pub(super) fn set_node_scroll_position( unsafe { &*runtime_ptr }.note_realtime_layout_input_change(); } if changed && queue_observable_effects { + // Admission only: ActionBatch still owns its checkpoints and the + // Page arbiter decides when the asynchronous frame may execute. + unsafe { &*runtime_ptr }.queue_realtime_element_scroll_events(scope, handle); queue_scroll_observable_effects(scope, runtime_ptr, document, false); } changed diff --git a/moli-renderer-v8/src/page_task_queue/rendering_update/source.rs b/moli-renderer-v8/src/page_task_queue/rendering_update/source.rs index be59251705..2f54b51546 100644 --- a/moli-renderer-v8/src/page_task_queue/rendering_update/source.rs +++ b/moli-renderer-v8/src/page_task_queue/rendering_update/source.rs @@ -63,6 +63,10 @@ pub(crate) struct RendererPageRenderingUpdateSender { } impl RendererPageRenderingUpdateSender { + pub(crate) fn is_open(&self) -> bool { + self.route.route.is_open() + } + pub(crate) fn new( route: RendererPageRenderingUpdateRoute, root_document: RendererDocumentToken, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs index d1dabdf741..f0f7598b2f 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs @@ -60,6 +60,7 @@ mod painting_tables; mod realtime_animation_frames; mod realtime_capture; mod realtime_environment; +mod realtime_events; mod realtime_input; mod realtime_intersection_observer; mod realtime_opportunity; diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_environment.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_environment.rs index b16b0704d5..3ea468a23c 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_environment.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_environment.rs @@ -2,7 +2,7 @@ use super::realtime_opportunity::run_opportunity; use super::*; use moli_page_types::LayoutPolicy; -fn check_fixture(fixture: &'static str, expected_rows: serde_json::Value) { +pub(super) fn check_fixture(fixture: &'static str, expected_rows: serde_json::Value) { run_page_vm_large_stack_async_test("realtime-environment", move || async move { let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap(); let (mut page, _, _) = page_vm_with_bound_task_sources_and_owner_wake( diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_events.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_events.rs new file mode 100644 index 0000000000..f3b5f256a1 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_events.rs @@ -0,0 +1,239 @@ +use super::realtime_environment::check_fixture; +use super::realtime_opportunity::run_opportunity; +use super::*; +use moli_page_types::LayoutPolicy; + +#[test] +fn realtime_scroll_phase_orders_documents_and_snapshots_reentrant_batches() { + // Chromium 145 matches the first and third rows. Its unfiltered later + // animation-event drain also dispatches reentrant scroll before rAF. + // Blink 157's stable EventTimingMatchingHTML path explicitly excludes + // scroll there; like the HTML phase model, we defer the new batch. + check_fixture( + include_str!("../../../../../tests/fixtures/realtime-scroll-phases.js"), + serde_json::json!([ + [ + "phases", + [ + ["resize", 480], + ["visual", 480], + ["scroll", 20], + ["scrollend", 20], + ["child-scroll", 15], + ["child-scrollend", 15], + ["media", true], + ["raf"] + ] + ], + [ + "reentrant", + [ + ["scroll", 30], + ["microtask"], + ["scrollend", 40], + ["raf"], + ["scroll", 40], + ["scrollend", 40] + ] + ], + [ + "element", + [["element-scroll", 20], ["element-scrollend", 20], ["raf"]] + ] + ]), + ); +} + +#[test] +fn realtime_element_scroll_drives_dom_and_further_scroll_without_polling() { + check_fixture( + include_str!("../../../../../tests/fixtures/realtime-scroll-driven-pane.js"), + serde_json::json!([ + ["scroll", 40, false, false, true], + ["scrollend", 80, false, false, true], + ["scroll", 80, false, false, true], + ["scrollend", 120, false, false, true], + ["scroll", 120, false, false, true], + ["scrollend", 120, false, false, true], + ]), + ); +} + +#[test] +fn realtime_autofocus_and_animation_scan_share_the_ordered_frame() { + run_page_vm_large_stack_async_test("realtime-pre-animation", || async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let (mut page, _, _) = page_vm_with_bound_task_sources_and_owner_wake( + &loader, + Url::parse("https://example.com/realtime-events").unwrap(), + ); + let viewport = |width| crate::protocol_types::ViewportSurface { + inner_width: width, + inner_height: 600, + ..Default::default() + }; + page.vm_mut() + .set_viewport_surface_for_bootstrap(Some(viewport(800))); + page.vm_mut().set_layout_policy(LayoutPolicy::Realtime); + run_opportunity(&mut page, &loader).await.unwrap(); + page.vm_mut().eval(r#" + document.body.style.height='3000px'; + document.body.innerHTML='
'; + document.head.appendChild(document.createElement('style')).textContent= + '@keyframes move {from {left:0px} to {left:10px}} #moving {position:relative;animation:move 1s linear}'; + globalThis.events=[]; + document.addEventListener('DOMContentLoaded',()=>{ + events.push('dcl');Promise.resolve().then(()=>events.push('dcl-microtask')); + }); + entry.addEventListener('focus',()=>{ + events.push('focus');Promise.resolve().then(()=>events.push('focus-microtask')); + }); + addEventListener('resize',()=>events.push('resize')); + document.addEventListener('scroll',()=>{ + events.push('scroll');Promise.resolve().then(()=>events.push('scroll-microtask')); + }); + document.addEventListener('scrollend',()=>events.push('scrollend')); + matchMedia('(min-width:900px)').onchange=()=>events.push('media'); + moving.addEventListener('animationstart',()=>events.push('animation')); + scrollTo(0,20); + requestAnimationFrame(()=>events.push('raf')); + "#).unwrap(); + dispatch_main_document_domcontentloaded_for_rendering_test(&mut page) + .await + .unwrap(); + page.vm_mut() + .set_viewport_surface(Some(viewport(960))) + .unwrap(); + assert_eq!( + page.vm_mut().eval("events.join('|')").unwrap(), + "dcl|dcl-microtask" + ); + let deadline = page + .next_rendering_opportunity_deadline_for_test() + .expect("events must retain their rendering opportunity"); + // A slow fixture setup can legitimately reach the frame deadline. + // It must still select the shared frame, never a legacy event task. + if let Some(claimed) = page + .claim_exact_selected_page_task_for_test(PageSelectedTaskTestSelector::RenderingUpdate) + { + assert_eq!( + claimed.rendering_update_owner_and_kind().unwrap().1, + RendererPageRenderingUpdateTaskKind::FrameLifecycle, + "no immediate legacy event task may bypass the deadline" + ); + assert!(std::time::Instant::now() >= deadline); + page.run_claimed_selected_page_task_for_test(claimed, &loader) + .await + .unwrap(); + } else { + run_opportunity(&mut page, &loader).await.unwrap(); + } + assert_eq!( + page.vm_mut().eval("events.join('|')").unwrap(), + "dcl|dcl-microtask|focus|focus-microtask|resize|scroll|scroll-microtask|scrollend|media|animation|raf" + ); + assert!( + page.next_rendering_opportunity_deadline_for_test() + .is_none() + ); + assert!( + page.claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::RenderingUpdate + ) + .is_none() + ); + }); +} + +#[test] +fn realtime_scroll_survives_explicit_document_open_before_and_during_delivery() { + run_page_vm_large_stack_async_test("realtime-scroll-document-open", || async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let (mut page, _, _) = page_vm_with_bound_task_sources_and_owner_wake( + &loader, + Url::parse("https://example.com/realtime-scroll-open").unwrap(), + ); + page.vm_mut().set_layout_policy(LayoutPolicy::Realtime); + page.vm_mut() + .eval("document.body.style.height='3000px'") + .unwrap(); + run_opportunity(&mut page, &loader).await.unwrap(); + page.vm_mut().eval(r#" + globalThis.events=[]; + scrollTo(0,100); + document.open();document.write('');document.close(); + document.addEventListener('scroll',()=>events.push('scroll:'+scrollY)); + document.addEventListener('scrollend',()=>events.push('scrollend:'+scrollY)); + "#).unwrap(); + run_opportunity(&mut page, &loader).await.unwrap(); + assert_eq!( + page.vm_mut().eval("events.join('|')").unwrap(), + "scroll:100|scrollend:100" + ); + page.vm_mut().eval(r#" + events=[]; + document.addEventListener('scroll',()=>{ + document.open();document.write('');document.close(); + document.addEventListener('scrollend',()=>events.push('current-end:'+scrollY)); + Promise.resolve().then(()=>events.push('microtask')); + },{once:true}); + scrollTo(0,200); + "#).unwrap(); + run_opportunity(&mut page, &loader).await.unwrap(); + assert_eq!( + page.vm_mut().eval("events.join('|')").unwrap(), + "scroll:200|microtask|current-end:200" + ); + assert!( + page.next_rendering_opportunity_deadline_for_test() + .is_none() + ); + }); +} + +#[test] +fn realtime_scroll_skips_detached_child_and_keeps_later_child_phase_runnable() { + run_page_vm_large_stack_async_test("realtime-scroll-child-owner", || async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let (mut page, _, _) = page_vm_with_bound_task_sources_and_owner_wake( + &loader, + Url::parse("https://example.com/realtime-scroll-child").unwrap(), + ); + page.vm_mut().set_layout_policy(LayoutPolicy::Realtime); + page.vm_mut() + .eval( + r#" + document.body.style.height='3000px'; + for (const id of ['retired','current']) { + const f=document.createElement('iframe');f.id=id;document.body.append(f); + f.contentDocument.body.style.height='2000px'; + } + globalThis.events=[]; + globalThis.retiredFrame=document.getElementById('retired'); + globalThis.currentFrame=document.getElementById('current'); + retiredFrame.contentDocument.addEventListener('scroll',()=>events.push('retired')); + currentFrame.contentDocument.addEventListener('scroll',()=>events.push('current')); + document.addEventListener('scroll',()=>{ + events.push('root'); + retiredFrame.remove(); + currentFrame.contentWindow.scrollTo(0,40); + }); + "#, + ) + .unwrap(); + run_opportunity(&mut page, &loader).await.unwrap(); + run_opportunity(&mut page, &loader).await.unwrap(); + page.vm_mut() + .eval("retiredFrame.contentWindow.scrollTo(0,20);scrollTo(0,30)") + .unwrap(); + run_opportunity(&mut page, &loader).await.unwrap(); + assert_eq!( + page.vm_mut().eval("events.join('|')").unwrap(), + "root|current" + ); + assert!( + page.next_rendering_opportunity_deadline_for_test() + .is_none() + ); + }); +} diff --git a/moli-renderer-v8/src/script_vm/rendering_update/realtime.rs b/moli-renderer-v8/src/script_vm/rendering_update/realtime.rs index cf5f4a5655..2b7f9927c5 100644 --- a/moli-renderer-v8/src/script_vm/rendering_update/realtime.rs +++ b/moli-renderer-v8/src/script_vm/rendering_update/realtime.rs @@ -41,7 +41,7 @@ impl ScriptVm { // One monotonic sample per opportunity, then each target Window's // internal time origin. Neither performance nor Date is read from JS. let epoch_ms = moli_time::monotonic_timestamp_seconds() * 1000.0; - let mut invoked = Self::run_realtime_environment_phases(scope, host_ptr, id, root)?; + let mut invoked = Self::run_realtime_pre_animation_phases(scope, host_ptr, id, root)?; let mut next_document_index = 0; while let Some(target) = unsafe { &*host_ptr }.realtime_rendering_document(id, root, next_document_index) diff --git a/moli-renderer-v8/src/script_vm/rendering_update/realtime_environment.rs b/moli-renderer-v8/src/script_vm/rendering_update/realtime_environment.rs index cad58b8796..03e724fc91 100644 --- a/moli-renderer-v8/src/script_vm/rendering_update/realtime_environment.rs +++ b/moli-renderer-v8/src/script_vm/rendering_update/realtime_environment.rs @@ -7,21 +7,29 @@ use crate::{ native_bridge::{ OwnerDispatchScope, PageScriptHost, WindowDocumentTaskTarget, WindowExecutionContextBinding, }, - page_task_queue::RendererPageRenderingUpdateTaskId, + page_task_queue::{RendererPageRenderingUpdateTaskId, RendererPageRenderingUpdateTaskKind}, v8_execution_watchdog::{ SCRIPT_TURN_WATCHDOG_TIMEOUT, V8ExecutionWatchdog, V8ExecutionWatchdogKind, V8ExecutionWatchdogOutcome, }, }; +enum PreAnimationPhase { + Queued(RendererPageRenderingUpdateTaskKind), + Resize, + Media, +} + impl ScriptVm { - pub(super) fn run_realtime_environment_phases( + pub(super) fn run_realtime_pre_animation_phases( scope: &mut v8::PinScope<'_, '_>, host_ptr: *mut PageScriptHost, id: RendererPageRenderingUpdateTaskId, root: WindowDocumentTaskTarget, ) -> Result { - if !unsafe { &*host_ptr }.has_realtime_environment_notifications() { + if !unsafe { &*host_ptr }.has_realtime_environment_notifications() + && !unsafe { &*host_ptr }.has_realtime_rendering_events() + { return Ok(false); } let watchdog = V8ExecutionWatchdog::arm( @@ -30,16 +38,16 @@ impl ScriptVm { SCRIPT_TURN_WATCHDOG_TIMEOUT, ); let result = - Self::dispatch_realtime_environment_phases(scope, host_ptr, id, root, &watchdog); + Self::dispatch_realtime_pre_animation_phases(scope, host_ptr, id, root, &watchdog); let timeout = watchdog.timeout(); ensure!( watchdog.disarm() != V8ExecutionWatchdogOutcome::TimedOut, - "environment lifecycle exceeded {timeout:?} and was terminated" + "pre-animation lifecycle exceeded {timeout:?} and was terminated" ); result } - fn dispatch_realtime_environment_phases( + fn dispatch_realtime_pre_animation_phases( scope: &mut v8::PinScope<'_, '_>, host_ptr: *mut PageScriptHost, id: RendererPageRenderingUpdateTaskId, @@ -47,22 +55,65 @@ impl ScriptVm { watchdog: &V8ExecutionWatchdog, ) -> Result { let mut invoked = false; - // All Documents' resize steps precede all Documents' media steps. + // Follow the corresponding Blink PageAnimator/HTML phases. This + // migrates the existing animation compatibility scan, not a timeline. // Take each Document's batch only when entering that phase: a parent // callback can publish a new child viewport before the child's turn. - for phase in 0..2 { + for phase in [ + PreAnimationPhase::Queued(RendererPageRenderingUpdateTaskKind::PostParseAutofocus), + PreAnimationPhase::Resize, + PreAnimationPhase::Queued(RendererPageRenderingUpdateTaskKind::DocumentScrollEvents), + PreAnimationPhase::Media, + PreAnimationPhase::Queued(RendererPageRenderingUpdateTaskKind::AnimationStartScan), + ] { let mut index = 0; while let Some(target) = unsafe { &*host_ptr }.realtime_rendering_document(id, root, index) { + let document_index = index; index += 1; if !unsafe { &*host_ptr }.realtime_rendering_opportunity_is_current(id, root) { return Ok(invoked); } + if let PreAnimationPhase::Queued(kind) = phase { + let mut events = unsafe { &*host_ptr } + .take_realtime_rendering_events(target, kind) + .into_iter(); + while let Some(event) = events.next() { + if !unsafe { &*host_ptr } + .realtime_rendering_opportunity_is_current(id, root) + { + return Ok(invoked); + } + // Only document.open can transfer the running list. + // Navigation/detachment cannot authorize a new owner. + let Some(target) = unsafe { &*host_ptr }.realtime_rendering_document( + id, + root, + document_index, + ) else { + break; + }; + invoked |= unsafe { &mut *host_ptr } + .dispatch_realtime_rendering_event(scope, host_ptr, target, event); + if let Err(error) = Self::environment_checkpoint(scope, watchdog) { + if let Some(target) = unsafe { &*host_ptr }.realtime_rendering_document( + id, + root, + document_index, + ) { + unsafe { &*host_ptr } + .restore_realtime_rendering_events(target, events.collect()); + } + return Err(error); + } + } + continue; + } let Some(binding) = environment_binding(scope, host_ptr, target) else { continue; }; - if phase == 0 { + if matches!(phase, PreAnimationPhase::Resize) { let Some(mut pending) = unsafe { &*host_ptr }.take_realtime_resize_notification(target) else { @@ -158,7 +209,7 @@ impl ScriptVm { Self::perform_microtask_checkpoints(scope, None)?; ensure!( !watchdog.has_timed_out() && !scope.is_execution_terminating(), - "environment lifecycle was terminated" + "pre-animation lifecycle was terminated" ); Ok(()) } diff --git a/moli-renderer-v8/tests/fixtures/realtime-scroll-document-open.js b/moli-renderer-v8/tests/fixtures/realtime-scroll-document-open.js new file mode 100644 index 0000000000..c3c6dee7df --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/realtime-scroll-document-open.js @@ -0,0 +1,26 @@ +(async () => { + const settle = async () => { + for (let i = 0; i < 4; ++i) + await new Promise(resolve => requestAnimationFrame(resolve)); + }; + const markup = ''; + document.body.style.height = '3000px'; + await settle(); + let events = []; + const rows = []; + scrollTo(0, 100); + document.open(); document.write(markup); document.close(); + document.addEventListener('scroll', () => events.push(['scroll', scrollY])); + document.addEventListener('scrollend', () => events.push(['scrollend', scrollY])); + await settle(); + rows.push(['before-delivery', events]); events = []; + document.addEventListener('scroll', () => { + document.open(); document.write(markup); document.close(); + document.addEventListener('scrollend', () => events.push(['current-end', scrollY])); + Promise.resolve().then(() => events.push(['microtask'])); + }, { once: true }); + scrollTo(0, 200); + await settle(); + rows.push(['during-delivery', events]); + return { rows, failures: [] }; +})() diff --git a/moli-renderer-v8/tests/fixtures/realtime-scroll-driven-pane.js b/moli-renderer-v8/tests/fixtures/realtime-scroll-driven-pane.js new file mode 100644 index 0000000000..e92d3ccd8d --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/realtime-scroll-driven-pane.js @@ -0,0 +1,28 @@ +(async () => { + document.body.innerHTML = '
'; + const pane = document.getElementById('pane'); + const content = document.getElementById('content'); + pane.getBoundingClientRect(); + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + const rows = []; + let count = 0; + // After initialization only native scroll notifications drive the next + // DOM update and scroll. No timer, rAF, capture, or polling advances it. + await new Promise(resolve => { + pane.addEventListener('scroll', event => { + ++count; + rows.push(['scroll', pane.scrollTop, event.bubbles, event.cancelable, + event.target === pane]); + content.style.height = (600 + count * 100) + 'px'; + if (count < 3) pane.scrollTop += 40; + }); + pane.addEventListener('scrollend', event => { + rows.push(['scrollend', pane.scrollTop, event.bubbles, event.cancelable, + event.target === pane]); + if (count === 3) resolve(); + }); + pane.scrollTop = 40; + }); + return { rows, failures: [] }; +})() diff --git a/moli-renderer-v8/tests/fixtures/realtime-scroll-phases.js b/moli-renderer-v8/tests/fixtures/realtime-scroll-phases.js new file mode 100644 index 0000000000..d85c2d0e60 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/realtime-scroll-phases.js @@ -0,0 +1,49 @@ +(async () => { + const tick = () => new Promise(resolve => requestAnimationFrame(resolve)); + const settle = async () => { for (let i=0;i<4;++i) await tick(); }; + document.documentElement.style.cssText='scroll-behavior:auto'; + document.body.style.cssText='margin:0;height:3000px'; + const frame=document.createElement('iframe'); + frame.style.cssText='display:block;width:320px;height:100px;border:0'; + document.body.append(frame); + const child=frame.contentWindow; + child.document.open();child.document.write('');child.document.close(); + child.innerWidth; + await settle(); + let events=[]; + const rows=[]; + const query=child.matchMedia('(min-width:400px)'); + child.addEventListener('resize',()=>events.push(['resize',child.innerWidth])); + child.visualViewport.addEventListener('resize',()=>events.push(['visual',child.visualViewport.width])); + query.addEventListener('change',e=>events.push(['media',e.matches])); + document.addEventListener('scroll',()=>events.push(['scroll',scrollY])); + document.addEventListener('scrollend',()=>events.push(['scrollend',scrollY])); + child.document.addEventListener('scroll',()=>events.push(['child-scroll',child.scrollY])); + child.document.addEventListener('scrollend',()=>events.push(['child-scrollend',child.scrollY])); + frame.style.width='480px';child.innerWidth; + scrollTo(0,10);scrollTo(0,20);child.scrollTo(0,15); + requestAnimationFrame(()=>events.push(['raf'])); + await settle(); + rows.push(['phases',events]);events=[]; + let reentered=false; + document.addEventListener('scroll',()=>{ + if(reentered)return; + reentered=true; + scrollTo(0,40); + Promise.resolve().then(()=>events.push(['microtask'])); + }); + scrollTo(0,30); + requestAnimationFrame(()=>events.push(['raf'])); + await settle(); + rows.push(['reentrant',events]);events=[]; + const pane=document.createElement('div'); + pane.style.cssText='overflow:auto;width:100px;height:100px'; + pane.innerHTML='
'; + document.body.append(pane);pane.getBoundingClientRect();await settle();events=[]; + pane.addEventListener('scroll',()=>events.push(['element-scroll',pane.scrollTop])); + pane.addEventListener('scrollend',()=>events.push(['element-scrollend',pane.scrollTop])); + pane.scrollTop=10;pane.scrollTop=20; + requestAnimationFrame(()=>events.push(['raf']));await settle(); + rows.push(['element',events]); + return {rows,failures:[]}; +})()