mirror of
https://github.com/lexmount/moli.git
synced 2026-11-13 08:08:10 +00:00
fix(renderer): integrate realtime scroll and pre-animation phases
This commit is contained in:
@@ -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:[]};
|
||||
})()
|
||||
Reference in New Issue
Block a user