fix(renderer): integrate realtime scroll and pre-animation phases

This commit is contained in:
ldm0
2026-11-13 06:12:42 +08:00
parent a12055f827
commit 3bb60ad8ea
18 changed files with 758 additions and 15 deletions
@@ -74,6 +74,12 @@ impl<T, S: OwnerTaskReadySignal> OwnerReadyTaskRoute<T, S> {
&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);
}
@@ -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);
}
@@ -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;
@@ -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<u64, RendererPageRenderingUpdateTaskKind, RealtimeEventPayload>;
type PendingEvent =
PendingExactWindowDocumentTask<u64, RendererPageRenderingUpdateTaskKind, RealtimeEventPayload>;
/// 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<PreparedRealtimeRenderingEvent> {
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<PreparedRealtimeRenderingEvent>,
) {
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(),
)
});
}
}
@@ -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<RealtimeFrameState>,
pub(super) animation_frames: RefCell<RealtimeAnimationFrames>,
pub(super) environment: RefCell<RealtimeEnvironmentState>,
pub(super) rendering_events: RefCell<RealtimeRenderingEvents>,
}
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()),
}
}
}
@@ -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();
}
@@ -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,
@@ -68,6 +68,29 @@ impl<I, K, P, T> Default for WindowTaskLedger<I, K, P, T> {
}
impl<I: Copy + Eq, K: Copy + Eq, P, T: Copy + Eq> WindowTaskLedger<I, K, P, T> {
pub(super) fn iter(&self) -> impl Iterator<Item = &PendingWindowTask<I, K, P, T>> {
self.pending.iter()
}
pub(super) fn retain(&mut self, keep: impl FnMut(&PendingWindowTask<I, K, P, T>) -> bool) {
self.pending.retain(keep);
}
pub(super) fn prepend(&mut self, pending: PendingWindowTask<I, K, P, T>) {
self.pending.insert(0, pending);
}
pub(super) fn take_where(
&mut self,
mut matches: impl FnMut(&PendingWindowTask<I, K, P, T>) -> bool,
) -> Vec<PendingWindowTask<I, K, P, T>> {
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");
@@ -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
@@ -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,
@@ -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;
@@ -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(
@@ -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='<input id=entry autofocus><div id=moving></div>';
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('<!doctype html><body style="height:3000px"></body>');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('<!doctype html><body style="height:3000px"></body>');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()
);
});
}
@@ -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)
@@ -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<bool> {
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<bool> {
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(())
}
@@ -0,0 +1,26 @@
(async () => {
const settle = async () => {
for (let i = 0; i < 4; ++i)
await new Promise(resolve => requestAnimationFrame(resolve));
};
const markup = '<!doctype html><body style="height:3000px"></body>';
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: [] };
})()
@@ -0,0 +1,28 @@
(async () => {
document.body.innerHTML = '<div id="pane" style="overflow:auto;width:100px;height:100px"><div id="content" style="height:600px"></div></div>';
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: [] };
})()
@@ -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('<!doctype html><html style="overflow:hidden"><body style="height:2000px;margin:0"></body>');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='<div style="height:600px"></div>';
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:[]};
})()