From ee092ec49953bb47a2d7e04a9190fede5e2d0805 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 7 Oct 2026 22:58:54 +0800 Subject: [PATCH] fix(svg): dispatch native SMIL timing events --- moli-renderer-v8/src/context_bootstrap.rs | 1 + .../src/context_bootstrap/event_template.rs | 21 +- .../src/context_bootstrap/events.rs | 3 + .../src/context_bootstrap/events/base.rs | 1 + .../src/context_bootstrap/events/time.rs | 81 +++ .../src/context_bootstrap/specs/registry.rs | 4 + .../svg_runtime/animation.rs | 6 +- .../src/document_runtime/mutation_commands.rs | 13 + .../context_host/animation_frames.rs | 36 +- .../context_host/child_events.rs | 18 + .../context_host/rendering_updates.rs | 22 +- .../native_bridge/context_host/svg_smil.rs | 147 +++- .../context_host/svg_smil/events.rs | 646 ++++++++++++++++++ .../src/page_task_queue/rendering_update.rs | 1 + .../src/script_vm/browsing_contexts.rs | 2 +- moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../tests/svg_smil_time_event_brand.js | 54 ++ .../script_vm/tests/svg_smil_time_events.rs | 290 ++++++++ moli-renderer-v8/src/v8_execution_watchdog.rs | 6 +- moli-renderer-v8/src/web_api_interfaces.rs | 1 + moli-svg/src/lib.rs | 5 +- moli-svg/src/timing.rs | 17 +- moli-svg/src/timing/events.rs | 324 +++++++++ 23 files changed, 1647 insertions(+), 53 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/events/time.rs create mode 100644 moli-renderer-v8/src/native_bridge/context_host/svg_smil/events.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/svg_smil_time_event_brand.js create mode 100644 moli-renderer-v8/src/script_vm/tests/svg_smil_time_events.rs create mode 100644 moli-svg/src/timing/events.rs diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index e56c97d880..feabfee009 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -170,6 +170,7 @@ pub(crate) use self::css_runtime::{ pub(crate) use self::events::{ construct_native_touch_event, construct_original_event, construct_original_page_transition_event, construct_original_storage_event_utf16, + construct_svg_time_event, }; pub(crate) use self::touch_runtime::construct_native_touch; pub(crate) use crypto::{ diff --git a/moli-renderer-v8/src/context_bootstrap/event_template.rs b/moli-renderer-v8/src/context_bootstrap/event_template.rs index 9f036d14b0..a84c2ed731 100644 --- a/moli-renderer-v8/src/context_bootstrap/event_template.rs +++ b/moli-renderer-v8/src/context_bootstrap/event_template.rs @@ -26,13 +26,14 @@ use super::{ interaction_event_source_getter, keyboard_event_init_callback, message_event_init_callback, mouse_event_related_target_getter_function, pointer_event_get_predicted_events_callback, submit_event_agent_invoked_getter_function, submit_event_respond_with_callback, - submit_event_submitter_getter_function, track_event_track_getter_function, - ui_event_init_callback, ui_event_pseudo_target_getter_function, - ui_event_which_getter_function, + submit_event_submitter_getter_function, time_event_init_callback, + track_event_track_getter_function, ui_event_init_callback, + ui_event_pseudo_target_getter_function, ui_event_which_getter_function, }, selection_surface::document_get_selection_callback, specs::ConstructorSpec, }; +use crate::util::v8str; use crate::web_api_interfaces; use crate::{native_bridge::document, window_host}; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiFunctionTemplateDeclaration}; @@ -131,6 +132,19 @@ struct EventBaseTemplateMethodsDeclaration { init_event: (), } +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::TimeEvent, enumerable, receiver)] +struct TimeEventTemplateDeclaration { + #[webapi(accessor_property, getter = event_platform_attribute_getter, + data = v8str(scope, "view"))] + view: (), + #[webapi(accessor_property, getter = event_value_attribute_getter, + data = v8str(scope, "detail"))] + detail: (), + #[webapi(method = "initTimeEvent", length = 1, callback = time_event_init_callback)] + init_time_event: (), +} + #[derive(WebApiFunctionTemplate)] #[webapi(interface = web_api_interfaces::BeforeUnloadEvent, enumerable, receiver)] struct BeforeUnloadEventTemplateAccessorsDeclaration { @@ -642,6 +656,7 @@ fn install_declaration<'s, D: WebApiFunctionTemplateDeclaration>( fn event_template_declaration(interface: &str) -> Option { Some(match interface { "Event" => EventTemplateDeclaration::new::(), + "TimeEvent" => EventTemplateDeclaration::new::(), "BeforeUnloadEvent" => { EventTemplateDeclaration::new::() } diff --git a/moli-renderer-v8/src/context_bootstrap/events.rs b/moli-renderer-v8/src/context_bootstrap/events.rs index 4cf2d54c36..79f7e739ee 100644 --- a/moli-renderer-v8/src/context_bootstrap/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/events.rs @@ -12,6 +12,7 @@ mod methods; mod modifiers; mod subclasses; mod submit; +mod time; mod ui; mod value; mod wrappers; @@ -36,6 +37,8 @@ pub(crate) use subclasses::construct_native_touch_event; pub(in crate::context_bootstrap) use subclasses::input_event_get_target_ranges_callback; pub(in crate::context_bootstrap) use subclasses::interaction_event_source_getter; pub(in crate::context_bootstrap) use subclasses::keyboard_event_init_callback; +pub(crate) use time::construct_svg_time_event; +pub(in crate::context_bootstrap) use time::time_event_init_callback; pub(in crate::context_bootstrap) use ui::{ composition_event_init_callback, initialize_legacy_ui_event, ui_event_init_callback, ui_event_which_getter_function, diff --git a/moli-renderer-v8/src/context_bootstrap/events/base.rs b/moli-renderer-v8/src/context_bootstrap/events/base.rs index b7d2a99ce4..542bd6c193 100644 --- a/moli-renderer-v8/src/context_bootstrap/events/base.rs +++ b/moli-renderer-v8/src/context_bootstrap/events/base.rs @@ -22,6 +22,7 @@ pub(crate) fn event_backing<'s>( event: v8::Local<'_, v8::Object>, ) -> v8::Local<'s, v8::Object> { let event = v8::Local::new(scope, event); + let event = moli_webapi_declare::web_api_object_target(scope, event).unwrap_or(event); crate::util::get_private_object(scope, event, EVENT_BACKING_SLOT).unwrap_or(event) } diff --git a/moli-renderer-v8/src/context_bootstrap/events/time.rs b/moli-renderer-v8/src/context_bootstrap/events/time.rs new file mode 100644 index 0000000000..35897581a1 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/events/time.rs @@ -0,0 +1,81 @@ +//! TimeEvent instances are produced by the native SVG timeline. The interface +//! has no public constructor and is not a Document.createEvent legacy alias. + +use super::{ + define_event_property, initialize_event_object, mark_event_trusted, new_event_state, + new_event_wrapper, reinitialize_event_object, +}; +use crate::{web_api_interfaces, webidl}; + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "TimeEvent.initTimeEvent")] +struct InitTimeEventArgs<'s> { + #[webidl(required, converter = "raw")] + event_type: webidl::DomString16, + #[webidl(nullable, interface = web_api_interfaces::Window, + brand_check = super::super::is_window_receiver)] + view: Option>, + #[webidl(default = 0)] + detail: i32, +} + +pub(in crate::context_bootstrap) fn time_event_init_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + let event = moli_webapi_declare::web_api_object_target(scope, args.this()) + .expect("generated TimeEvent receiver validation"); + if !reinitialize_event_object(scope, event, &parsed.event_type.0, false, false) { + return; + } + let view = parsed + .view + .map_or_else(|| v8::null(scope).into(), Into::into); + define_event_property(scope, event, "view", view); + define_event_property( + scope, + event, + "detail", + v8::Integer::new(scope, parsed.detail).into(), + ); +} + +pub(crate) fn construct_svg_time_event<'s>( + scope: &mut v8::PinScope<'s, '_>, + kind: moli_svg::SvgAnimationEventKind, +) -> Option> { + let state = new_event_state(scope); + initialize_event_object(scope, state, kind.name(), false, false); + let view = scope.get_current_context().global(scope); + define_event_property(scope, state, "view", view.into()); + let detail = match kind { + moli_svg::SvgAnimationEventKind::Repeat(iteration) => { + // TimeEvent.detail is a WebIDL long, including its modulo conversion. + let number = v8::Number::new(scope, iteration); + webidl::convert::( + scope, + number.into(), + webidl::Context::member("TimeEvent", "detail"), + ) + .ok()? + .0 + } + _ => 0, + }; + define_event_property( + scope, + state, + "detail", + v8::Integer::new(scope, detail).into(), + ); + web_api_interfaces::TimeEvent::DESCRIPTOR + .initialize(scope, state) + .ok()?; + let event = new_event_wrapper(scope, state)?; + mark_event_trusted(scope, event); + Some(event) +} diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index c60ce0697d..ff36d82da1 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -4,6 +4,10 @@ use crate::web_api_interfaces; use std::collections::HashSet; const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[ + ConstructorSpec { + interface: web_api_interfaces::TimeEvent::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, ConstructorSpec { interface: web_api_interfaces::TrustedTypePolicy::DESCRIPTOR, kind: ConstructorKind::TrustedTypePolicy, diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/animation.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/animation.rs index 300d79d1bb..a05e673ea5 100644 --- a/moli-renderer-v8/src/context_bootstrap/svg_runtime/animation.rs +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/animation.rs @@ -162,7 +162,7 @@ fn add_instance<'s>( offset: f32, ) { if let Ok((runtime, handle)) = svg_receiver_runtime_and_handle(scope, args) { - unsafe { &*runtime }.add_svg_animation_instance(handle, begin, offset); + unsafe { &mut *runtime }.add_svg_animation_instance(handle, begin, offset); } } @@ -226,7 +226,7 @@ fn set_current_time<'s>( return; }; if let Ok((runtime, handle)) = svg_receiver_runtime_and_handle(scope, &args) { - unsafe { &*runtime }.seek_svg_animations(handle, parsed.seconds); + unsafe { &mut *runtime }.seek_svg_animations(handle, parsed.seconds); } } @@ -236,7 +236,7 @@ fn set_paused<'s>( pause: bool, ) { if let Ok((runtime, handle)) = svg_receiver_runtime_and_handle(scope, args) { - unsafe { &*runtime }.pause_svg_animations(handle, pause); + unsafe { &mut *runtime }.pause_svg_animations(handle, pause); } } diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index f7bcf16f89..a1b66a9691 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -1890,6 +1890,7 @@ pub(crate) struct RuntimeMutationApplyResult { changed_text_controls: Vec<(DomHandle, bool)>, number_list_attribute_projections: Vec, + svg_animation_documents: Vec, } impl RuntimeMutationApplyResult { @@ -1936,8 +1937,15 @@ pub(super) fn finish_runtime_mutation_effects( font_face_use_roots, changed_text_controls, number_list_attribute_projections, + svg_animation_documents, } = result; + for document in svg_animation_documents { + if unsafe { &*host_ptr }.document_ready_state_for_handle(document) == "complete" { + unsafe { &mut *host_ptr }.queue_svg_animation_document_update(document); + } + } + context_bootstrap::svg_runtime::apply_number_list_attribute_projections( scope, number_list_attribute_projections, @@ -2354,6 +2362,11 @@ pub(super) fn prepare_runtime_mutation_effects( font_face_use_roots, changed_text_controls: Vec::new(), number_list_attribute_projections: Vec::new(), + svg_animation_documents: if document_followups_deferred_to_parser_owner { + Vec::new() + } else { + JsContextHost::svg_animation_mutation_documents(dom_host, effects) + }, } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/animation_frames.rs b/moli-renderer-v8/src/native_bridge/context_host/animation_frames.rs index 44679ab35d..2ed9eca40d 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/animation_frames.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/animation_frames.rs @@ -34,6 +34,7 @@ struct AnimationFrameProvider { wake_pending: bool, focus_fixup_target: Option, observer_target: Option, + svg_target: Option, handles: Vec, callbacks: HashMap, } @@ -86,6 +87,7 @@ impl AnimationFrameState { wake_pending: false, focus_fixup_target: None, observer_target: None, + svg_target: None, handles: Vec::new(), callbacks: HashMap::new(), }); @@ -195,6 +197,21 @@ impl JsContextHost { self.queue_window_animation_frame_wake(scope, binding); } + pub(super) fn queue_svg_animation_rendering_wake( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + target: WindowDocumentTaskTarget, + ) { + let dispatch_scope = target.dispatch_scope(); + let Some(owner) = self.current_window_execution_context_owner(dispatch_scope) else { + return; + }; + self.animation_frames + .ensure_provider(owner, dispatch_scope) + .svg_target = Some(target); + self.ensure_document_rendering_wake(scope, owner, dispatch_scope); + } + pub(crate) fn request_window_animation_frame<'s>( &mut self, scope: &mut v8::PinScope<'s, '_>, @@ -284,9 +301,13 @@ impl JsContextHost { if provider.observer_target != Some(target) { provider.observer_target = None; } + if provider.svg_target != Some(target) { + provider.svg_target = None; + } if provider.callbacks.is_empty() && provider.focus_fixup_target.is_none() && provider.observer_target.is_none() + && provider.svg_target.is_none() { provider.wake_pending = false; provider.handles.clear(); @@ -329,15 +350,26 @@ impl JsContextHost { invoked |= self.dispatch_authorized_post_parse_autofocus(scope, host_ptr, top); } - let handles = self + let (handles, sample_svg) = self .animation_frames .provider_mut(owner) .map(|provider| { provider.wake_pending = false; provider.observer_target = None; - std::mem::take(&mut provider.handles) + ( + std::mem::take(&mut provider.handles), + provider.svg_target.take() == Some(target), + ) }) .unwrap_or_default(); + if sample_svg { + invoked |= self.dispatch_authorized_svg_animation_events( + scope, + host_ptr, + target, + resolved.document_handle, + ); + } for handle in handles { if !self.window_execution_context_owner_is_current(owner, dispatch_scope) { self.animation_frames.discard(owner); diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_events.rs b/moli-renderer-v8/src/native_bridge/context_host/child_events.rs index 7bd9171e32..e4bd1e65c5 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_events.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_events.rs @@ -61,6 +61,24 @@ struct ReadyChildWindowEventListenerInvocation { } impl JsContextHost { + pub(super) fn child_window_has_event_listener( + &self, + target: ChildWindowEventTarget, + event_type: &(impl EventTypeKey + ?Sized), + ) -> bool { + self.child_window_event_target_is_current(target) + && self + .child_window_event_listeners + .get(&target.child_handle()) + .and_then(|listeners| listeners.get(event_type)) + .is_some_and(|listeners| { + listeners.iter().any(|entry| { + entry.local_window_id == Some(target.owner().local_window_id) + && entry.callback_state.callback_id().is_some() + }) + }) + } + fn child_window_event_requires_runtime_dispatch( &self, handle: DomHandle, 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 e18a693dd8..bd5ca08dec 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 @@ -10,6 +10,7 @@ use crate::{ pub(super) enum PendingRenderingUpdatePayload { DocumentScrollEvents, AnimationStartScan(EventTargetHandle), + SvgAnimationEvents(crate::document_runtime::DomHandle), /// Flush a top-level Document's autofocus candidates after DOMContentLoaded. /// The candidate is intentionally resolved at execution time. PostParseAutofocus, @@ -275,6 +276,9 @@ impl JsContextHost { PendingRenderingUpdatePayload::AnimationStartScan(event_target) => { self.dispatch_authorized_animation_start_scan(scope, host_ptr, target, event_target) } + PendingRenderingUpdatePayload::SvgAnimationEvents(document) => { + self.dispatch_authorized_svg_animation_events(scope, host_ptr, target, document) + } PendingRenderingUpdatePayload::PostParseAutofocus => { self.dispatch_authorized_post_parse_autofocus(scope, host_ptr, target) } @@ -315,13 +319,17 @@ impl JsContextHost { let Some(pending) = self.rendering_updates.remove(task_id) else { return false; }; - if let PendingRenderingUpdatePayload::AnimationFrameCallbacks(owner) = - pending.into_payload() - { - // document.open() rotates the parser/lifecycle incarnation without - // replacing the Window's animation callback map. Retire the stale - // rendering entry and admit a new exact-Document entry explicitly. - self.publish_animation_frame_rendering_update(owner); + match pending.into_payload() { + PendingRenderingUpdatePayload::AnimationFrameCallbacks(owner) => { + // document.open() rotates the parser/lifecycle incarnation without + // replacing the Window's animation callback map. Retire the stale + // rendering entry and admit a new exact-Document entry explicitly. + self.publish_animation_frame_rendering_update(owner); + } + PendingRenderingUpdatePayload::SvgAnimationEvents(document) => { + self.discard_svg_document_events(document); + } + _ => {} } true } diff --git a/moli-renderer-v8/src/native_bridge/context_host/svg_smil.rs b/moli-renderer-v8/src/native_bridge/context_host/svg_smil.rs index 5b72d31b2f..233c32e6ca 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/svg_smil.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/svg_smil.rs @@ -11,18 +11,32 @@ use style::values::{ }; use super::JsContextHost; -use crate::{document_runtime::DomHandle, native_bridge::document::SVG_NS}; +use crate::{document_runtime::DomHandle, dom::native::DomHost, native_bridge::document::SVG_NS}; + +mod events; + +fn is_animation_element(dom: &DomHost, handle: DomHandle) -> bool { + dom.node(handle).is_some_and(|node| { + node.namespace() == Some(SVG_NS) + && matches!( + node.local_name(), + Some("animate" | "set" | "animateMotion" | "animateTransform") + ) + }) +} pub(super) struct SvgSmilState { epoch: Instant, clocks: HashMap, instances: HashMap, effects: HashMap, + events: events::SvgEventState, } struct FragmentEffects { version: u64, targets: HashMap>, + animations: Vec, } impl Default for SvgSmilState { @@ -32,6 +46,7 @@ impl Default for SvgSmilState { clocks: HashMap::new(), instances: HashMap::new(), effects: HashMap::new(), + events: events::SvgEventState::default(), } } } @@ -43,6 +58,13 @@ impl SvgSmilState { } impl JsContextHost { + #[cfg(test)] + pub(crate) fn advance_svg_clocks_for_test(&self, elapsed: std::time::Duration) { + // Advance natural time without invoking the author-facing seek API or + // changing the event source's last sampled position. + self.svg_smil.borrow_mut().epoch -= elapsed; + } + /// All nested SVG elements in one SVG document fragment share its clock. /// An intervening non-SVG element (for example in foreignObject) starts a /// new fragment. Merely having an associated synthetic Window is not enough. @@ -66,22 +88,32 @@ impl JsContextHost { root } - pub(crate) fn record_svg_document_begin(&self, document: DomHandle) { + pub(crate) fn record_svg_document_begin(&mut self, document: DomHandle) { if self.window_endpoint_for_document(document).is_none() { return; } let mut state = self.svg_smil.borrow_mut(); let now = state.now(); + let mut started = Vec::new(); let mut nodes = vec![document]; while let Some(handle) = nodes.pop() { if self.dom_host().node(handle).is_some_and(|node| { node.namespace() == Some(SVG_NS) && node.local_name() == Some("svg") }) && self.svg_animation_fragment(handle) == Some(handle) { - state.clocks.entry(handle).or_default().start(now); + let clock = state.clocks.entry(handle).or_default(); + if !clock.has_started() { + clock.start(now); + started.push(handle); + } } nodes.extend(self.dom_host().child_nodes(handle).unwrap_or_default()); } + drop(state); + for root in started { + self.initialize_svg_animation_event_positions(root); + } + self.queue_svg_animation_document_update(document); } fn with_svg_clock( @@ -115,8 +147,9 @@ impl JsContextHost { .is_some_and(|root| self.with_svg_clock(root, |clock, _| clock.is_paused())) } - pub(crate) fn pause_svg_animations(&self, handle: DomHandle, pause: bool) { + pub(crate) fn pause_svg_animations(&mut self, handle: DomHandle, pause: bool) { if self.svg_animation_fragment(handle) == Some(handle) { + self.prepare_svg_fragment_events(handle); self.with_svg_clock(handle, |clock, now| { if pause { clock.pause(now); @@ -124,12 +157,22 @@ impl JsContextHost { clock.unpause(now); } }); + if let Some(document) = self.dom_host().owner_document_handle(handle) { + self.queue_svg_animation_document_update(document); + } } } - pub(crate) fn seek_svg_animations(&self, handle: DomHandle, seconds: f32) { + pub(crate) fn seek_svg_animations(&mut self, handle: DomHandle, seconds: f32) { if self.svg_animation_fragment(handle) == Some(handle) { + self.prepare_svg_fragment_events(handle); + let before = self.svg_presentation_time(handle); self.with_svg_clock(handle, |clock, now| clock.seek(now, f64::from(seconds))); + let after = self.svg_presentation_time(handle); + self.prepare_svg_seek_events(handle, before, after); + if let Some(document) = self.dom_host().owner_document_handle(handle) { + self.queue_svg_animation_document_update(document); + } } } @@ -236,7 +279,7 @@ impl JsContextHost { } pub(crate) fn add_svg_animation_instance( - &self, + &mut self, animation: DomHandle, begin: bool, offset: f32, @@ -244,7 +287,23 @@ impl JsContextHost { if self.svg_animation_fragment(animation).is_none() { return; } - let time = self.svg_presentation_time(animation) + f64::from(offset); + let root = self + .svg_animation_fragment(animation) + .expect("checked SVG fragment"); + self.prepare_svg_fragment_events(root); + let current_time = self.svg_presentation_time(animation); + let timing = self.svg_animation_timing(animation); + let before = { + let state = self.svg_smil.borrow(); + timing.active_interval( + state + .instances + .get(&animation) + .unwrap_or(&SvgAnimationInstanceTimes::default()), + current_time, + ) + }; + let time = current_time + f64::from(offset); let mut state = self.svg_smil.borrow_mut(); let instances = state.instances.entry(animation).or_default(); let list = if begin { @@ -255,6 +314,47 @@ impl JsContextHost { if !list.contains(&time) { list.push(time); } + let after = timing.active_interval(instances, current_time); + drop(state); + self.prepare_svg_instance_events(root, animation, before, after, current_time); + if let Some(document) = self.dom_host().owner_document_handle(animation) { + self.queue_svg_animation_document_update(document); + } + } + + fn ensure_svg_fragment_effects(&self, root: DomHandle) { + let dom = self.dom_host(); + let version = dom.dom_version(); + let mut state = self.svg_smil.borrow_mut(); + if state + .effects + .get(&root) + .is_some_and(|effects| effects.version == version) + { + return; + } + let mut targets: HashMap> = HashMap::new(); + let mut animations = Vec::new(); + let mut nodes = vec![root]; + while let Some(handle) = nodes.pop() { + if is_animation_element(dom, handle) + && self.svg_animation_fragment(handle) == Some(root) + { + animations.push(handle); + if let Some(target) = self.svg_animation_target(handle) { + targets.entry(target).or_default().push(handle); + } + } + nodes.extend(dom.child_handles_reversed(handle)); + } + state.effects.insert( + root, + FragmentEffects { + version, + targets, + animations, + }, + ); } /// Native path sampling is applied to an observation's typed style, shared @@ -278,35 +378,18 @@ impl JsContextHost { return None; } let animations = { - let mut state = self.svg_smil.borrow_mut(); - let version = dom.dom_version(); - if state - .effects - .get(&root) - .is_none_or(|effects| effects.version != version) - { - let mut targets: HashMap> = HashMap::new(); - let mut nodes = vec![root]; - while let Some(handle) = nodes.pop() { - if dom.node(handle).is_some_and(|node| { - node.namespace() == Some(SVG_NS) - && matches!(node.local_name(), Some("animate" | "set")) - }) && self.svg_animation_fragment(handle) == Some(root) - && let Some(target) = self.svg_animation_target(handle) - { - targets.entry(target).or_default().push(handle); - } - let children = dom.child_nodes(handle).unwrap_or_default(); - nodes.extend(children.into_iter().rev()); - } - state - .effects - .insert(root, FragmentEffects { version, targets }); - } + self.ensure_svg_fragment_effects(root); + let state = self.svg_smil.borrow(); state.effects.get(&root)?.targets.get(&target)?.clone() }; let mut result = None; for animation in animations { + if !dom + .node(animation) + .is_some_and(|node| matches!(node.local_name(), Some("animate" | "set"))) + { + continue; + } if dom.get_attribute(animation, "attributeName").as_deref() != Some("d") { continue; } diff --git a/moli-renderer-v8/src/native_bridge/context_host/svg_smil/events.rs b/moli-renderer-v8/src/native_bridge/context_host/svg_smil/events.rs new file mode 100644 index 0000000000..4c41d9d967 --- /dev/null +++ b/moli-renderer-v8/src/native_bridge/context_host/svg_smil/events.rs @@ -0,0 +1,646 @@ +//! Native SVG timing events use the Document rendering source. Timer wakes do +//! not dispatch script, and every event revalidates the exact Document owner. + +use std::collections::{HashMap, VecDeque}; + +use moli_svg::{ + SvgAnimationEvent, SvgAnimationEventKind, SvgAnimationEvents, SvgAnimationInterval, +}; + +use super::super::{ + JsContextHost, OwnerDispatchScope, WindowDocumentTaskTarget, + rendering_updates::PendingRenderingUpdatePayload, +}; +use crate::{ + document_runtime::{DomHandle, EventTargetHandle}, + dom::native::{DomHost, DomMutationEffects}, + native_bridge::document::SVG_NS, + page_task_queue::RendererPageRenderingUpdateTaskKind, +}; + +#[derive(Default)] +pub(super) struct SvgEventState { + positions: HashMap, + roots: HashMap)>, + pending: HashMap>>, +} + +#[derive(Clone, Copy)] +struct EventPosition { + document: DomHandle, + root: DomHandle, + time: f64, +} + +struct EventStream { + animation: DomHandle, + iterator: EventIterator, + next: Option, +} + +enum EventIterator { + Natural(SvgAnimationEvents), + Transition(std::vec::IntoIter), +} + +impl EventStream { + fn peek(&mut self) -> Option { + if self.next.is_none() { + self.next = match &mut self.iterator { + EventIterator::Natural(events) => events.next(), + EventIterator::Transition(events) => events.next(), + }; + } + self.next + } + + fn next_non_repeat_time(&self) -> Option { + if let Some(event) = self.next + && !matches!(event.kind, SvgAnimationEventKind::Repeat(_)) + { + return Some(event.time); + } + match &self.iterator { + EventIterator::Natural(events) => events.next_non_repeat_time(), + EventIterator::Transition(events) => events.as_slice().first().map(|event| event.time), + } + } + + fn skip_unobserved_repeats_before(&mut self, time: f64) { + self.next = None; + if let EventIterator::Natural(events) = &mut self.iterator { + events.skip_repeats_before(time); + } + } +} + +impl JsContextHost { + pub(crate) fn svg_animation_mutation_documents( + dom: &DomHost, + effects: &DomMutationEffects, + ) -> Vec { + fn contains_animation(dom: &DomHost, root: DomHandle) -> bool { + let mut nodes = vec![root]; + while let Some(handle) = nodes.pop() { + if super::is_animation_element(dom, handle) { + return true; + } + nodes.extend(dom.child_handles(handle)); + } + false + } + let mut documents = Vec::new(); + let attributes = effects + .style() + .attribute_mutations() + .iter() + .filter_map(|mutation| { + (dom.is_connected_to_document(mutation.target()) + && super::is_animation_element(dom, mutation.target()) + && mutation.namespace().is_none() + && matches!( + mutation.local_name(), + "begin" | "end" | "dur" | "repeatCount" | "repeatDur" | "restart" + )) + .then_some(mutation.target()) + }); + let child_lists = effects + .style() + .child_list_mutations() + .iter() + .filter_map(|mutation| { + (dom.is_connected_to_document(mutation.target()) + && mutation + .added_nodes() + .iter() + .chain(mutation.removed_nodes()) + .any(|&node| contains_animation(dom, node))) + .then_some(mutation.target()) + }); + for target in attributes.chain(child_lists) { + if let Some(document) = dom.owner_document_handle(target) + && !documents.contains(&document) + { + documents.push(document); + } + } + documents + } + + fn svg_document_roots(&self, document: DomHandle) -> Vec { + let version = self.dom_host().dom_version(); + if let Some((cached_version, roots)) = self.svg_smil.borrow().events.roots.get(&document) + && *cached_version == version + { + return roots.clone(); + } + let mut roots = Vec::new(); + let mut nodes = vec![document]; + while let Some(handle) = nodes.pop() { + if self.dom_host().node(handle).is_some_and(|node| { + node.namespace() == Some(SVG_NS) && node.local_name() == Some("svg") + }) && self.svg_animation_fragment(handle) == Some(handle) + { + roots.push(handle); + } + nodes.extend(self.dom_host().child_handles_reversed(handle)); + } + self.svg_smil + .borrow_mut() + .events + .roots + .insert(document, (version, roots.clone())); + roots + } + + fn svg_fragment_animations(&self, root: DomHandle) -> Vec { + self.ensure_svg_fragment_effects(root); + self.svg_smil + .borrow() + .effects + .get(&root) + .map(|effects| effects.animations.clone()) + .unwrap_or_default() + } + + pub(super) fn initialize_svg_animation_event_positions(&self, root: DomHandle) { + let Some(document) = self.dom_host().owner_document_handle(root) else { + return; + }; + let animations = self.svg_fragment_animations(root); + let initial_time = self.with_svg_clock(root, |clock, _| clock.initial_time()); + let mut streams = Vec::new(); + let mut state = self.svg_smil.borrow_mut(); + for animation in animations { + state.events.positions.insert( + animation, + EventPosition { + document, + root, + time: if initial_time == 0.0 { + -1.0 + } else { + initial_time + }, + }, + ); + if initial_time > 0.0 { + let timing = self.svg_animation_timing(animation); + let instances = state.instances.get(&animation).cloned().unwrap_or_default(); + let active = timing.active_interval(&instances, initial_time); + let events = SvgAnimationEvent::active_transition(None, active, initial_time) + .collect::>(); + if !events.is_empty() { + streams.push(EventStream { + animation, + iterator: EventIterator::Transition(events.into_iter()), + next: None, + }); + } + } + } + drop(state); + self.push_svg_event_batch(root, streams); + } + + pub(crate) fn queue_svg_animation_document_update(&mut self, document: DomHandle) { + self.retire_disconnected_svg_animations(document); + let Some(endpoint) = self.window_endpoint_for_document(document) else { + return; + }; + let Some(target) = + self.current_window_document_task_target_for_dispatch_scope(endpoint.dispatch_scope()) + else { + return; + }; + let mut has_animation = false; + for root in self.svg_document_roots(document) { + let was_started = self + .svg_smil + .borrow() + .clocks + .get(&root) + .is_some_and(|clock| clock.has_started()); + let started = self + .with_svg_clock(root, |clock, now| { + let started = clock.has_started(); + (started, clock.current_time(now)) + }) + .0; + if started { + if !was_started { + self.initialize_svg_animation_event_positions(root); + } + let animations = self.svg_fragment_animations(root); + has_animation |= !animations.is_empty(); + } + } + if !has_animation + && !self + .svg_smil + .borrow() + .events + .pending + .contains_key(&document) + { + return; + } + self.queue_rendering_update( + target, + RendererPageRenderingUpdateTaskKind::SvgAnimationEvents, + PendingRenderingUpdatePayload::SvgAnimationEvents(document), + ); + } + + fn push_svg_event_batch(&self, root: DomHandle, streams: Vec) { + if streams.is_empty() { + return; + } + if let Some(document) = self.dom_host().owner_document_handle(root) { + self.svg_smil + .borrow_mut() + .events + .pending + .entry(document) + .or_default() + .push_back(streams); + } + } + + pub(super) fn prepare_svg_fragment_events(&self, root: DomHandle) { + let Some(document) = self.dom_host().owner_document_handle(root) else { + return; + }; + let time = self.svg_presentation_time(root); + if !self.with_svg_clock(root, |clock, _| clock.has_started()) { + return; + } + let animations = self.svg_fragment_animations(root); + let mut streams = Vec::new(); + for animation in animations { + let timing = self.svg_animation_timing(animation); + let mut state = self.svg_smil.borrow_mut(); + let previous = state + .events + .positions + .get(&animation) + .copied() + .filter(|position| position.root == root && position.document == document); + let instances = state.instances.get(&animation).cloned().unwrap_or_default(); + let iterator = match previous { + Some(previous) => { + EventIterator::Natural(timing.events_between(&instances, previous.time, time)) + } + None => { + let after = timing.active_interval(&instances, time); + EventIterator::Transition( + SvgAnimationEvent::active_transition(None, after, time) + .collect::>() + .into_iter(), + ) + } + }; + state.events.positions.insert( + animation, + EventPosition { + document, + root, + time, + }, + ); + drop(state); + let mut stream = EventStream { + animation, + iterator, + next: None, + }; + if stream.peek().is_some() { + streams.push(stream); + } + } + self.push_svg_event_batch(root, streams); + } + + pub(super) fn prepare_svg_seek_events(&self, root: DomHandle, before: f64, after: f64) { + let Some(document) = self.dom_host().owner_document_handle(root) else { + return; + }; + if !self.with_svg_clock(root, |clock, _| clock.has_started()) { + return; + } + let mut streams = Vec::new(); + for animation in self.svg_fragment_animations(root) { + let timing = self.svg_animation_timing(animation); + let mut state = self.svg_smil.borrow_mut(); + let instances = state.instances.get(&animation).cloned().unwrap_or_default(); + let events = SvgAnimationEvent::active_transition( + timing.active_interval(&instances, before), + timing.active_interval(&instances, after), + after, + ) + .collect::>(); + state.events.positions.insert( + animation, + EventPosition { + document, + root, + time: after, + }, + ); + if !events.is_empty() { + streams.push(EventStream { + animation, + iterator: EventIterator::Transition(events.into_iter()), + next: None, + }); + } + } + self.push_svg_event_batch(root, streams); + } + + pub(super) fn prepare_svg_instance_events( + &self, + root: DomHandle, + animation: DomHandle, + before: Option, + after: Option, + time: f64, + ) { + if !self.with_svg_clock(root, |clock, _| clock.has_started()) { + return; + } + let events = SvgAnimationEvent::active_transition(before, after, time).collect::>(); + self.push_svg_event_batch( + root, + if events.is_empty() { + Vec::new() + } else { + vec![EventStream { + animation, + iterator: EventIterator::Transition(events.into_iter()), + next: None, + }] + }, + ); + } + + fn has_svg_repeat_listener(&self, animation: DomHandle) -> bool { + let path = self.build_propagation_path(EventTargetHandle::Node(animation), false); + let window = match self.owner_dispatch_scope_for_node(animation) { + Some(OwnerDispatchScope::Top) => EventTargetHandle::Window, + Some(OwnerDispatchScope::Child(handle)) => { + let Some(target) = self.current_child_window_event_target(handle) else { + return false; + }; + EventTargetHandle::ChildWindow(target) + } + Some(OwnerDispatchScope::LightweightPopup(id)) => { + let Some(target) = self.current_popup_window_event_target(id) else { + return false; + }; + EventTargetHandle::PopupWindow(target) + } + None => return false, + }; + path.into_iter() + .map(|target| { + if target == EventTargetHandle::Window { + window + } else { + target + } + }) + .any(|target| match target { + EventTargetHandle::ChildWindow(target) => { + self.child_window_has_event_listener(target, "repeatEvent") + } + _ => self.has_event_listener(target, "repeatEvent"), + }) + } + + fn take_svg_event(&self, document: DomHandle) -> Option<(DomHandle, SvgAnimationEvent)> { + let mut state = self.svg_smil.borrow_mut(); + let batches = state.events.pending.get_mut(&document)?; + loop { + let Some(streams) = batches.front_mut() else { + state.events.pending.remove(&document); + return None; + }; + let index = streams + .iter_mut() + .enumerate() + .filter_map(|(index, stream)| stream.peek().map(|event| (index, event))) + .min_by(|(a, first), (b, second)| { + first + .time + .total_cmp(&second.time) + .then(first.kind.order().cmp(&second.kind.order())) + .then(a.cmp(b)) + }) + .map(|(index, _)| index); + let Some(index) = index else { + batches.pop_front(); + continue; + }; + let event = streams[index].next.expect("selected event"); + if matches!(event.kind, SvgAnimationEventKind::Repeat(_)) + && !self.has_svg_repeat_listener(streams[index].animation) + { + // Begin/end handlers can add a repeat listener. Do not skip + // beyond another stream's next opportunity to run author code. + let until = streams + .iter() + .filter_map(EventStream::next_non_repeat_time) + .min_by(f64::total_cmp) + .unwrap_or(f64::INFINITY); + streams[index].skip_unobserved_repeats_before(until); + continue; + } + let stream = &mut streams[index]; + stream.next = None; + return Some((stream.animation, event)); + } + } + + fn svg_document_has_future_events(&self, document: DomHandle) -> bool { + if self + .svg_smil + .borrow() + .events + .pending + .contains_key(&document) + { + return true; + } + for root in self.svg_document_roots(document) { + let current_time = self.svg_presentation_time(root); + let through = if self.svg_animations_paused(root) { + current_time + } else { + f64::INFINITY + }; + for animation in self.svg_fragment_animations(root) { + let timing = self.svg_animation_timing(animation); + let state = self.svg_smil.borrow(); + // Callbacks and microtasks can cross a boundary after the + // rendering source sampled it. Such a boundary still needs a + // wake, even when it is now in the presentation clock's past. + let time = state + .events + .positions + .get(&animation) + .filter(|position| position.root == root && position.document == document) + .map_or(current_time, |position| position.time); + let instances = state.instances.get(&animation).cloned().unwrap_or_default(); + if timing + .events_between(&instances, time, through) + .next() + .is_some() + { + return true; + } + } + } + false + } + + pub(in crate::native_bridge::context_host) fn dispatch_authorized_svg_animation_events( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + host_ptr: *mut JsContextHost, + target: WindowDocumentTaskTarget, + expected_document: DomHandle, + ) -> bool { + let Some(resolved) = self.resolve_authorized_window_document_task_context(scope, target) + else { + self.discard_svg_document_events(expected_document); + return false; + }; + if resolved.document_handle != expected_document { + self.discard_svg_document_events(expected_document); + return false; + } + let scope = &mut v8::ContextScope::new(scope, resolved.context); + let previous = target.dispatch_scope().enter(scope); + let watchdog = crate::v8_execution_watchdog::V8ExecutionWatchdog::arm( + crate::v8_execution_watchdog::V8ExecutionWatchdogKind::SvgAnimationEvents, + scope.thread_safe_handle(), + crate::v8_execution_watchdog::SCRIPT_TURN_WATCHDOG_TIMEOUT, + ); + for root in self.svg_document_roots(expected_document) { + self.prepare_svg_fragment_events(root); + } + let mut invoked = false; + // Yield through the existing rendering source instead of monopolizing + // a script turn when a long clock jump has many observed repetitions. + for _ in 0..256 { + if scope.is_execution_terminating() { + break; + } + if !self.window_document_owner_is_current_for_dispatch_scope( + target.owner(), + target.dispatch_scope(), + ) { + self.discard_svg_document_events(expected_document); + break; + } + let Some((animation, event)) = self.take_svg_event(expected_document) else { + break; + }; + // Already queued boundaries keep their target after removal. The + // removal step terminates its interval and stops future sampling; + // its begin/end tasks still belong to this exact Document. + if self.dom_host().owner_document_handle(animation) != Some(expected_document) { + continue; + } + let Some(event) = crate::context_bootstrap::construct_svg_time_event(scope, event.kind) + else { + continue; + }; + let _ = self.dispatch_public_event_best_effort( + scope, + host_ptr, + EventTargetHandle::Node(animation), + event, + "SVG timing event", + ); + invoked = true; + } + if watchdog.disarm() == crate::v8_execution_watchdog::V8ExecutionWatchdogOutcome::TimedOut { + tracing::warn!("SVG timing events exceeded their execution deadline"); + } + if self.window_document_owner_is_current_for_dispatch_scope( + target.owner(), + target.dispatch_scope(), + ) && self.svg_document_has_future_events(expected_document) + { + self.queue_svg_animation_rendering_wake(scope, target); + } + target.dispatch_scope().restore(scope, previous); + invoked + } + + pub(in crate::native_bridge::context_host) fn discard_svg_document_events( + &self, + document: DomHandle, + ) { + let mut state = self.svg_smil.borrow_mut(); + state.events.pending.remove(&document); + state.events.roots.remove(&document); + state + .events + .positions + .retain(|_, position| position.document != document); + } + + fn retire_disconnected_svg_animations(&self, document: DomHandle) { + let current = |animation, root| { + self.dom_host().is_connected_to_document(animation) + && self.dom_host().owner_document_handle(animation) == Some(document) + && self.svg_animation_fragment(animation) == Some(root) + }; + let retired: Vec<_> = self + .svg_smil + .borrow() + .events + .positions + .iter() + .filter(|&(&animation, position)| { + position.document == document && !current(animation, position.root) + }) + .map(|(&animation, position)| (animation, *position)) + .collect(); + for (animation, position) in retired { + let time = self.svg_presentation_time(position.root); + let timing = self.svg_animation_timing(animation); + let mut state = self.svg_smil.borrow_mut(); + let instances = state.instances.get(&animation).cloned().unwrap_or_default(); + state.events.positions.remove(&animation); + let mut natural = EventStream { + animation, + iterator: EventIterator::Natural(timing.events_between( + &instances, + position.time, + time, + )), + next: None, + }; + let active = timing.active_interval(&instances, time); + let end = SvgAnimationEvent::active_transition(active, None, time).collect::>(); + let batches = state.events.pending.entry(document).or_default(); + if natural.peek().is_some() { + batches.push_back(vec![natural]); + } + if !end.is_empty() { + batches.push_back(vec![EventStream { + animation, + iterator: EventIterator::Transition(end.into_iter()), + next: None, + }]); + } + if batches.is_empty() { + state.events.pending.remove(&document); + } + } + } +} diff --git a/moli-renderer-v8/src/page_task_queue/rendering_update.rs b/moli-renderer-v8/src/page_task_queue/rendering_update.rs index 43cd38bcab..0740c436e5 100644 --- a/moli-renderer-v8/src/page_task_queue/rendering_update.rs +++ b/moli-renderer-v8/src/page_task_queue/rendering_update.rs @@ -35,6 +35,7 @@ impl RendererPageRenderingUpdateTaskId { pub(crate) enum RendererPageRenderingUpdateTaskKind { DocumentScrollEvents, AnimationStartScan, + SvgAnimationEvents, PostParseAutofocus, AnimationFrameCallbacks, IntersectionObserverDelivery, diff --git a/moli-renderer-v8/src/script_vm/browsing_contexts.rs b/moli-renderer-v8/src/script_vm/browsing_contexts.rs index 861aa2e73e..4987b5f51e 100644 --- a/moli-renderer-v8/src/script_vm/browsing_contexts.rs +++ b/moli-renderer-v8/src/script_vm/browsing_contexts.rs @@ -48,7 +48,7 @@ impl ScriptVm { self.document_runtime.set_document_ready_state(state); if state == crate::dom::native::DocumentReadyState::Complete { self._context_host - .borrow() + .borrow_mut() .record_svg_document_begin(self.document_runtime.document_handle()); } Ok(()) diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index ab547d4e80..0b3e7df971 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2347,6 +2347,7 @@ mod svg_path_data; mod svg_redraw_methods; mod svg_root_factory_receivers; mod svg_smil_path_timing; +mod svg_smil_time_events; mod svg_switch_mpath_interfaces; mod svg_tree_queries; mod svg_uri_reference; diff --git a/moli-renderer-v8/src/script_vm/tests/svg_smil_time_event_brand.js b/moli-renderer-v8/src/script_vm/tests/svg_smil_time_event_brand.js new file mode 100644 index 0000000000..c24979a18e --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/svg_smil_time_event_brand.js @@ -0,0 +1,54 @@ +(() => { + const check=(value,name)=>{if(!value)throw new Error(name)}; + const event=nativeTimeEvent; + const realm=document.querySelector('iframe').contentWindow; + const init=realm.TimeEvent.prototype.initTimeEvent; + check(event instanceof TimeEvent && event instanceof Event,'native interfaces'); + check(event.isTrusted && event.type==='repeatEvent' && event.detail===-1 && event.view===window,'native payload and long conversion'); + check(Object.getPrototypeOf(event)===TimeEvent.prototype,'native prototype'); + check(!Object.hasOwn(event,'view') && !Object.hasOwn(event,'detail'),'prototype attribute placement'); + check(Object.getPrototypeOf(TimeEvent.prototype)===Event.prototype,'Event inheritance'); + for(const R of [window,realm]){ + try { new R.TimeEvent(); throw 42; } catch(error){check(Object.getPrototypeOf(error)===R.TypeError.prototype,'illegal constructor realm')} + } + let conversions=0,traps=0; + const text={toString(){conversions++;return 'changed'}}; + const numeric={valueOf(){conversions++;return 7}}; + const proxy=new Proxy(event,{get(){traps++;throw 42},getPrototypeOf(){traps++;throw 42}}); + const revoked=Proxy.revocable(event,{});revoked.revoke(); + for(const receiver of [{},Object.create(TimeEvent.prototype),Object.create(event),proxy,revoked.proxy]){ + try { init.call(receiver,text,window,numeric); throw 42; } + catch(error){check(Object.getPrototypeOf(error)===realm.TypeError.prototype,'receiver error realm')} + } + check(conversions===0 && traps===0,'receiver validation before argument conversion'); + const original={marker:1}; + try { init.call(event,{toString(){throw original}},window,numeric);throw 42 } + catch(error){check(error===original,'original conversion exception')} + check(conversions===0 && event.type==='repeatEvent','conversion failure leaves state intact'); + const stamp=event.timeStamp; + init.call(event,'x\ud800',realm,4294967295); + check(event.type==='x\ud800' && event.detail===-1 && event.view===realm,'UTF-16 and cross realm Window'); + check(!event.isTrusted && event.timeStamp===stamp,'legacy initialization preserves creation time'); + init.call(event,'again'); + check(event.view===null && event.detail===0 && !event.bubbles && !event.cancelable,'optional argument defaults'); + init.call(nativeTimeEventProxy,'native-proxy',realm,9); + check(event.type==='native-proxy' && event.view===realm && event.detail===9,'registered native Proxy identity'); + const detail=Object.getOwnPropertyDescriptor(realm.TimeEvent.prototype,'detail').get; + check(detail.call(nativeTimeEventProxy)===9,'registered native Proxy accessor'); + init.call(event,'again'); + event.added=1; + const target=new EventTarget(); + target.addEventListener('again',()=>{ + init.call(event,text,window,numeric); + check(event.type==='again' && event.view===null && event.detail===0,'dispatching initialization is ignored'); + }); + target.dispatchEvent(event); + check(conversions===2,'dispatching events still convert arguments'); + const Original=TimeEvent; + globalThis.TimeEvent=function(){throw 42}; + try { check(event instanceof Original,'author replacement retains native identity') } + finally { globalThis.TimeEvent=Original } + try { document.createEvent('TimeEvent');throw 42 } + catch(error){check(error.name==='NotSupportedError','TimeEvent is not a legacy createEvent alias')} + return 'time-event:ok'; +})() diff --git a/moli-renderer-v8/src/script_vm/tests/svg_smil_time_events.rs b/moli-renderer-v8/src/script_vm/tests/svg_smil_time_events.rs new file mode 100644 index 0000000000..03f23f6ab6 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/svg_smil_time_events.rs @@ -0,0 +1,290 @@ +use super::*; +use crate::network::ResourceRequestClient; + +#[test] +fn native_time_events_preserve_brands_realms_and_legacy_argument_order() { + let mut vm = new_storage_page_task_executor_test_vm("https://svg-time-event.test/"); + vm.eval("document.body.innerHTML=''") + .unwrap(); + let context = &vm.page_default_context as *const _; + vm.with_context_scope_by_ptr_and_checkpoint_for_test(context, |scope, _| { + let event = crate::context_bootstrap::construct_svg_time_event( + scope, + moli_svg::SvgAnimationEventKind::Repeat(4294967295.0), + ) + .unwrap(); + let global = scope.get_current_context().global(scope); + assert_eq!( + global.create_data_property( + scope, + crate::util::v8str(scope, "nativeTimeEvent").into(), + event.into() + ), + Some(true) + ); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, event, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + assert!(crate::web_api_interfaces::TimeEvent::is_instance( + scope, + proxy.into() + )); + assert_eq!( + global.create_data_property( + scope, + crate::util::v8str(scope, "nativeTimeEventProxy").into(), + proxy.into() + ), + Some(true) + ); + Ok(()) + }) + .unwrap(); + assert_eq!( + vm.eval(include_str!("svg_smil_time_event_brand.js")) + .unwrap(), + "time-event:ok" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_pending_seek_at_document_begin_does_not_replay_finished_intervals() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm("https://svg-pending-seek-events.test/"); + vm.eval(r#"document.body.innerHTML=''; globalThis.svg=document.querySelector('svg'); globalThis.events=[]; + for(const animation of document.querySelectorAll('set')) for(const type of ['beginEvent','repeatEvent','endEvent']) animation.addEventListener(type,event=>events.push(animation.id+':'+event.type)); + svg.pauseAnimations(); svg.setCurrentTime(10);"#).unwrap(); + assert_eq!(vm.eval("svg.getCurrentTime()").unwrap(), "0"); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "second:beginEvent"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_seeking_events_are_deferred_and_do_not_replay_skipped_intervals() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm("https://svg-seeking-events.test/"); + vm.eval(r#"document.body.innerHTML=''; globalThis.svg=document.querySelector('svg'); globalThis.events=[]; + for(const animation of document.querySelectorAll('set')) for(const type of ['beginEvent','repeatEvent','endEvent']) animation.addEventListener(type,event=>events.push(animation.id+':'+event.type+':'+event.detail));"#).unwrap(); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.eval("svg.pauseAnimations(); svg.setCurrentTime(10)") + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), ""); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "second:beginEvent:0"); + vm.eval("events.length=0; svg.setCurrentTime(5.5)").unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + assert_eq!( + vm.eval("events.join('|')").unwrap(), + "second:endEvent:0|first:beginEvent:0" + ); + vm.eval("events.length=0; svg.setCurrentTime(6.5)").unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), ""); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_time_events_progress_without_author_timers_or_frame_callbacks() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm("https://svg-autonomous-events.test/"); + vm.eval(r#"document.body.innerHTML=''; globalThis.events=[]; + for(const type of ['beginEvent','repeatEvent','endEvent']) animation.addEventListener(type,event=>events.push([event.type,event.detail,event.isTrusted,event instanceof TimeEvent,event.view===window].join(':')));"#).unwrap(); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + vm.advance_timers_until_deadline_for_test(&loader) + .await + .unwrap(); + assert_eq!( + vm.eval("events.join('|')").unwrap(), + "beginEvent:0:true:true:true|repeatEvent:1:true:true:true|endEvent:0:true:true:true" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_time_events_stop_when_a_listener_replaces_its_document() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm("https://svg-retired-events.test/"); + vm.eval(r#"document.body.innerHTML=''; globalThis.events=[]; + for(const animation of document.querySelectorAll('set')) for(const type of ['beginEvent','repeatEvent','endEvent']) animation.addEventListener(type,event=>events.push(animation.id+':'+event.type)); + first.onbegin=()=>{document.open();document.write('');document.close();};"#).unwrap(); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + vm.advance_timers_until_deadline_for_test(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "first:beginEvent"); + assert_eq!(vm.eval("document.body.id").unwrap(), "replacement"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_timing_events_skip_unobserved_repetitions_without_losing_the_end() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = + new_storage_page_task_executor_test_vm("https://svg-unobserved-repeat-events.test/"); + vm.eval(r#"document.body.innerHTML=''; globalThis.events=[]; + animation.onbegin=()=>events.push('begin'); animation.onend=()=>events.push('end');"#).unwrap(); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + vm.advance_timers_until_deadline_for_test(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "begin|end"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_begin_handler_microtasks_can_install_repeat_handlers() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = + new_storage_page_task_executor_test_vm("https://svg-repeat-handler-checkpoint.test/"); + vm.eval(r#"document.body.innerHTML=''; globalThis.events=[]; + animation.onbegin=()=>queueMicrotask(()=>{animation.onrepeat=event=>events.push(event.detail);}); animation.onend=()=>events.push('end');"#).unwrap(); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + vm.advance_timers_until_deadline_for_test(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "1|2|end"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_boundaries_crossed_during_a_handler_still_schedule_a_rendering_wake() { + fn advance_clock( + _scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments<'_>, + _rv: v8::ReturnValue<'_, v8::Value>, + ) { + let pointer = v8::Local::::try_from(args.data()) + .unwrap() + .value() + .cast::(); + unsafe { &*pointer }.advance_svg_clocks_for_test(std::time::Duration::from_secs(4_000)); + } + + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm("https://svg-crossed-boundaries.test/"); + let context = &vm.page_default_context as *const _; + vm.with_context_scope_by_ptr_and_checkpoint_for_test(context, |scope, host_ptr| { + let data = v8::External::new(scope, host_ptr.cast()); + let callback = v8::Function::builder(advance_clock) + .data(data.into()) + .build(scope) + .unwrap(); + let global = scope.get_current_context().global(scope); + assert_eq!( + global.create_data_property( + scope, + crate::util::v8str(scope, "advanceSvgClock").into(), + callback.into() + ), + Some(true) + ); + Ok(()) + }) + .unwrap(); + vm.eval(r#"document.body.innerHTML=''; globalThis.events=[]; + animation.onbegin=()=>{events.push('begin'); advanceSvgClock();}; + animation.onrepeat=event=>events.push(event.detail); animation.onend=()=>events.push('end');"#).unwrap(); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "begin"); + vm.advance_timers_until_deadline_for_test(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "begin|1|2|end"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_animation_removal_ends_the_interval_without_losing_queued_events() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm("https://svg-reinsert-events.test/"); + vm.eval(r#"document.body.innerHTML=''; globalThis.svg=document.querySelector('svg'); globalThis.animation=document.querySelector('set'); globalThis.events=[]; + for(const type of ['beginEvent','repeatEvent','endEvent']) animation.addEventListener(type,event=>events.push(event.type));"#).unwrap(); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.eval("svg.pauseAnimations();svg.setCurrentTime(6);animation.remove()") + .unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "beginEvent|endEvent"); + vm.eval("svg.append(animation)").unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + assert_eq!( + vm.eval("events.join('|')").unwrap(), + "beginEvent|endEvent|beginEvent" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn child_svg_repeat_events_reach_the_owning_windows_capture_listener() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm("https://svg-child-repeat-events.test/"); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + vm.eval("document.body.innerHTML=''") + .unwrap(); + vm.advance_timers_until_deadline_for_test(&loader) + .await + .unwrap(); + vm.eval(r#"globalThis.events=[]; globalThis.other=document.querySelector('iframe').contentWindow; + other.addEventListener('repeatEvent',other.Function('event', 'parent.events.push([event.detail,event.view===window,event instanceof TimeEvent].join(":"))'),true); + other.document.body.innerHTML='';"#).unwrap(); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + vm.advance_timers_until_deadline_for_test(&loader) + .await + .unwrap(); + assert_eq!(vm.eval("events.join('|')").unwrap(), "1:true:true"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 32)] +async fn svg_timing_event_watchdog_bounds_listeners_and_their_microtasks() { + use crate::v8_execution_watchdog::{V8ExecutionWatchdog, V8ExecutionWatchdogKind}; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + for runaway in ["while(true) {}", "queueMicrotask(()=>{while(true) {}})"] { + let mut vm = new_storage_page_task_executor_test_vm("https://svg-event-watchdog.test/"); + vm.eval(&format!(r#"document.body.innerHTML='';globalThis.calls=0;animation.onbegin=()=>{{calls++;{runaway}}};"#)).unwrap(); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete) + .unwrap(); + let _budget = V8ExecutionWatchdog::override_timeout_for_test( + V8ExecutionWatchdogKind::SvgAnimationEvents, + std::time::Duration::from_millis(100), + ); + vm.run_one_rendering_update_executor_turn(&loader) + .await + .unwrap(); + assert_eq!( + vm.eval("[calls,6*7].join('|')").unwrap(), + "1|42", + "{runaway}" + ); + } +} diff --git a/moli-renderer-v8/src/v8_execution_watchdog.rs b/moli-renderer-v8/src/v8_execution_watchdog.rs index c42dfc5dcc..e4da92831c 100644 --- a/moli-renderer-v8/src/v8_execution_watchdog.rs +++ b/moli-renderer-v8/src/v8_execution_watchdog.rs @@ -30,6 +30,7 @@ pub(crate) enum V8ExecutionWatchdogKind { LifecycleEvent, FocusFixup, RenderingObservers, + SvgAnimationEvents, } #[cfg(test)] @@ -42,6 +43,7 @@ impl V8ExecutionWatchdogKind { Self::AnimationFrameCallback => 3, Self::FocusFixup => 4, Self::RenderingObservers => 5, + Self::SvgAnimationEvents => 6, } } } @@ -49,8 +51,8 @@ impl V8ExecutionWatchdogKind { #[cfg(test)] std::thread_local! { static V8_EXECUTION_WATCHDOG_TIMEOUT_OVERRIDES: - std::cell::Cell<[Option; 6]> = - const { std::cell::Cell::new([None; 6]) }; + std::cell::Cell<[Option; 7]> = + const { std::cell::Cell::new([None; 7]) }; } #[cfg(test)] diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index 8e0c564089..1a4c6e9a19 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -296,6 +296,7 @@ interfaces! { FontFaceSet: EventTarget; FontFaceSetIterator = "FontFaceSet Iterator"; FontFaceSetLoadEvent: Event; + TimeEvent: Event; FormData; FormDataIterator = "FormData Iterator"; FormDataEvent: Event; diff --git a/moli-svg/src/lib.rs b/moli-svg/src/lib.rs index 014df6f430..01ef141780 100644 --- a/moli-svg/src/lib.rs +++ b/moli-svg/src/lib.rs @@ -18,8 +18,9 @@ pub use length::{ pub use matrix::{SvgMatrixComponents, serialize_number}; pub use path::{SvgPathSegment, parse_path_segments}; pub use timing::{ - SvgAnimationInstanceTimes, SvgAnimationInterval, SvgAnimationRestart, SvgAnimationTiming, - SvgPresentationClock, parse_clock_value, + SvgAnimationEvent, SvgAnimationEventKind, SvgAnimationEvents, SvgAnimationInstanceTimes, + SvgAnimationInterval, SvgAnimationRestart, SvgAnimationTiming, SvgPresentationClock, + parse_clock_value, }; pub use transform::{ SvgTransform, SvgTransformKind, consolidate_transform_matrices, parse_transform_attribute, diff --git a/moli-svg/src/timing.rs b/moli-svg/src/timing.rs index a8d0ecb558..458b0f94f1 100644 --- a/moli-svg/src/timing.rs +++ b/moli-svg/src/timing.rs @@ -2,11 +2,15 @@ //! Wall time is supplied by the owner, so seeking never rewrites DOM attributes //! and paused clocks can be sampled deterministically. +mod events; +pub use events::{SvgAnimationEvent, SvgAnimationEventKind, SvgAnimationEvents}; + #[derive(Debug, Default)] pub struct SvgPresentationClock { started: bool, paused: bool, position: f64, + initial_position: f64, anchor: f64, } @@ -14,6 +18,7 @@ impl SvgPresentationClock { pub fn start(&mut self, now: f64) { if !self.started { self.started = true; + self.initial_position = self.position; self.anchor = now; } } @@ -57,6 +62,11 @@ impl SvgPresentationClock { pub fn has_started(&self) -> bool { self.started } + + /// The position chosen at Document begin, including a pending seek. + pub fn initial_time(&self) -> f64 { + self.initial_position + } } /// SMIL clock values. Offsets may have a sign; simple durations may not. @@ -167,7 +177,11 @@ impl SvgAnimationTiming { let mut ends = self.ends.clone(); ends.extend_from_slice(&instances.ends); ends.sort_by(f64::total_cmp); - let duration = (self.simple_duration * self.repeat_count).min(self.repeat_duration); + let duration = if self.simple_duration == 0.0 { + 0.0 + } else { + (self.simple_duration * self.repeat_count).min(self.repeat_duration) + }; let mut intervals: Vec = Vec::new(); for begin in begins { if let Some(previous) = intervals.last_mut() { @@ -235,6 +249,7 @@ mod tests { clock.pause(3.0); assert_eq!(clock.current_time(4.0), 0.0); clock.start(10.0); + assert_eq!(clock.initial_time(), 7.0); assert_eq!(clock.current_time(20.0), 7.0); clock.unpause(20.0); assert_eq!(clock.current_time(21.5), 8.5); diff --git a/moli-svg/src/timing/events.rs b/moli-svg/src/timing/events.rs new file mode 100644 index 0000000000..66c4a9e150 --- /dev/null +++ b/moli-svg/src/timing/events.rs @@ -0,0 +1,324 @@ +//! Animation timing events are separate from value sampling. Normal clock +//! progress visits interval boundaries; a seek only changes active membership. + +use super::{SvgAnimationInstanceTimes, SvgAnimationInterval, SvgAnimationTiming}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SvgAnimationEventKind { + Begin, + Repeat(f64), + End, +} + +impl SvgAnimationEventKind { + pub fn name(self) -> &'static str { + match self { + Self::Begin => "beginEvent", + Self::Repeat(_) => "repeatEvent", + Self::End => "endEvent", + } + } + + pub fn order(self) -> u8 { + match self { + Self::End => 0, + Self::Begin => 1, + Self::Repeat(_) => 2, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SvgAnimationEvent { + pub time: f64, + pub kind: SvgAnimationEventKind, +} + +/// An owned, lazy interval traversal. A renderer can yield between events +/// without allocating a vector proportional to the repeat count. +#[derive(Debug)] +pub struct SvgAnimationEvents { + intervals: std::vec::IntoIter, + current: Option, + duration: f64, + after: f64, + through: f64, + phase: u8, + repetition: f64, +} + +impl SvgAnimationTiming { + pub fn active_interval( + &self, + instances: &SvgAnimationInstanceTimes, + time: f64, + ) -> Option { + self.intervals(instances) + .into_iter() + .find(|interval| interval.begin <= time && time < interval.end) + } + + pub fn events_between( + &self, + instances: &SvgAnimationInstanceTimes, + after: f64, + through: f64, + ) -> SvgAnimationEvents { + SvgAnimationEvents { + intervals: self.intervals(instances).into_iter(), + current: None, + duration: self.simple_duration, + after, + through, + phase: 0, + repetition: 1.0, + } + } +} + +impl SvgAnimationEvent { + pub fn active_transition( + before: Option, + after: Option, + time: f64, + ) -> impl Iterator { + let changed = before.map(|interval| interval.begin) != after.map(|interval| interval.begin); + [ + (changed && before.is_some()).then_some(Self { + time, + kind: SvgAnimationEventKind::End, + }), + (changed && after.is_some()).then_some(Self { + time, + kind: SvgAnimationEventKind::Begin, + }), + ] + .into_iter() + .flatten() + } +} + +impl SvgAnimationEvents { + /// The next boundary that can run begin/end handlers. This permits a + /// renderer to skip unobserved repeat events only up to the next point + /// where author code could install a repeat listener. + pub fn next_non_repeat_time(&self) -> Option { + let current = self.current.into_iter().filter_map(|interval| { + if self.phase == 0 && self.contains(interval.begin.max(0.0)) { + Some(interval.begin.max(0.0)) + } else { + self.contains(interval.end).then_some(interval.end) + } + }); + current + .chain(self.intervals.as_slice().iter().filter_map(|interval| { + if self.contains(interval.begin.max(0.0)) { + Some(interval.begin.max(0.0)) + } else { + self.contains(interval.end).then_some(interval.end) + } + })) + .min_by(f64::total_cmp) + } + + pub fn skip_repeats_before(&mut self, time: f64) { + if self.phase == 1 + && self.duration.is_finite() + && self.duration > 0.0 + && let Some(interval) = self.current + { + self.repetition = self + .repetition + .max(((time - interval.begin) / self.duration).ceil()); + } + } + + fn contains(&self, time: f64) -> bool { + time.is_finite() && time >= 0.0 && self.after < time && time <= self.through + } +} + +impl Iterator for SvgAnimationEvents { + type Item = SvgAnimationEvent; + + fn next(&mut self) -> Option { + loop { + let interval = if let Some(interval) = self.current { + interval + } else { + let interval = self.intervals.next()?; + self.current = Some(interval); + self.phase = 0; + self.repetition = ((self.after.max(0.0) - interval.begin) / self.duration) + .floor() + .max(0.0) + + 1.0; + interval + }; + let (time, kind) = match self.phase { + 0 => { + self.phase = 1; + (interval.begin.max(0.0), SvgAnimationEventKind::Begin) + } + 1 if self.duration.is_finite() && self.duration > 0.0 => { + let time = interval.begin + self.repetition * self.duration; + if !time.is_finite() || time >= interval.end || time > self.through { + self.phase = 2; + continue; + } + let repetition = self.repetition; + self.repetition = (repetition + 1.0).max(repetition.next_up()); + (time, SvgAnimationEventKind::Repeat(repetition)) + } + 1 => { + self.phase = 2; + continue; + } + _ => { + self.current = None; + (interval.end, SvgAnimationEventKind::End) + } + }; + if self.contains(time) && (interval.end > 0.0 || interval.begin >= 0.0) { + return Some(SvgAnimationEvent { time, kind }); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SvgAnimationRestart; + + fn timing(duration: f64, count: f64) -> SvgAnimationTiming { + SvgAnimationTiming { + begins: vec![0.0], + ends: vec![], + simple_duration: duration, + repeat_count: count, + repeat_duration: f64::INFINITY, + freeze: false, + restart: SvgAnimationRestart::Always, + } + } + + #[test] + fn normal_progress_includes_begin_repeats_and_terminal_end_once() { + let timing = timing(1.0, 3.0); + let instances = SvgAnimationInstanceTimes::default(); + let rows: Vec<_> = timing.events_between(&instances, -1.0, 3.0).collect(); + assert_eq!( + rows.iter().map(|event| event.time).collect::>(), + [0.0, 1.0, 2.0, 3.0] + ); + assert_eq!( + rows.iter().map(|event| event.kind).collect::>(), + [ + SvgAnimationEventKind::Begin, + SvgAnimationEventKind::Repeat(1.0), + SvgAnimationEventKind::Repeat(2.0), + SvgAnimationEventKind::End + ] + ); + assert!(timing.events_between(&instances, 3.0, 4.0).next().is_none()); + } + + #[test] + fn seeking_changes_active_membership_without_skipped_repeat_events() { + let timing = timing(1.0, 3.0); + let instances = SvgAnimationInstanceTimes::default(); + let first = timing.active_interval(&instances, 0.5); + assert!( + SvgAnimationEvent::active_transition( + first, + timing.active_interval(&instances, 2.5), + 2.5 + ) + .next() + .is_none() + ); + let rows: Vec<_> = SvgAnimationEvent::active_transition(first, None, 5.0).collect(); + assert_eq!( + rows, + [SvgAnimationEvent { + time: 5.0, + kind: SvgAnimationEventKind::End + }] + ); + assert_eq!( + SvgAnimationEvent::active_transition(None, first, 0.5) + .next() + .unwrap() + .kind, + SvgAnimationEventKind::Begin + ); + } + + #[test] + fn restarted_intervals_end_before_the_replacement_begins() { + let mut timing = timing(3.0, 1.0); + timing.begins.push(1.0); + let rows: Vec<_> = timing + .events_between(&SvgAnimationInstanceTimes::default(), -1.0, 4.0) + .collect(); + assert_eq!( + rows.iter() + .map(|event| (event.time, event.kind)) + .collect::>(), + [ + (0.0, SvgAnimationEventKind::Begin), + (1.0, SvgAnimationEventKind::End), + (1.0, SvgAnimationEventKind::Begin), + (4.0, SvgAnimationEventKind::End) + ] + ); + } + + #[test] + fn repeat_duration_and_fractional_counts_preserve_the_final_partial_iteration() { + let mut timing = timing(2.0, 2.5); + timing.repeat_duration = 4.5; + let rows: Vec<_> = timing + .events_between(&SvgAnimationInstanceTimes::default(), -1.0, 5.0) + .collect(); + assert_eq!( + rows.iter().map(|event| event.time).collect::>(), + [0.0, 2.0, 4.0, 4.5] + ); + } + + #[test] + fn enormous_repeat_counts_are_lazy_and_unobserved_repeats_can_be_skipped() { + let timing = timing(0.001, 1e30); + let mut events = timing.events_between(&SvgAnimationInstanceTimes::default(), -1.0, 1e20); + assert_eq!(events.next().unwrap().kind, SvgAnimationEventKind::Begin); + assert_eq!( + events.next().unwrap().kind, + SvgAnimationEventKind::Repeat(1.0) + ); + events.skip_repeats_before(1e20); + let next = events.next(); + assert!(next.is_none() || next.unwrap().time >= 1e20); + } + + #[test] + fn zero_duration_and_intervals_before_document_begin_do_not_loop() { + let instances = SvgAnimationInstanceTimes::default(); + for count in [3.0, f64::INFINITY] { + let zero_duration = timing(0.0, count); + let rows: Vec<_> = zero_duration + .events_between(&instances, -1.0, 1.0) + .collect(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].kind, SvgAnimationEventKind::Begin); + assert_eq!(rows[1].kind, SvgAnimationEventKind::End); + assert_eq!(rows[1].time, 0.0); + } + let mut ended = timing(1.0, 1.0); + for begin in [-5.0, -1.0] { + ended.begins = vec![begin]; + assert!(ended.events_between(&instances, -1.0, 1.0).next().is_none()); + } + } +}