1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::rc::Rc;
4
5use crate::state::topmost_paths;
6use rhai::Dynamic;
7use thiserror::Error;
8
9use crate::{
10 AsyncDelivery, AsyncScope, CompiledUi, ComponentInstancePath, ComponentStateSchema,
11 EventSchema, ExecutionPhase, MotionError, RuntimeEngine, RuntimeError, ScriptCallback,
12 ScriptGeneration, StateError, UiContext, UiNode, UiRuntimeState, UiValue,
13};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum LifecycleState {
17 Created,
18 Initialized,
19 Running,
20 Suspended,
21 Disposed,
22}
23
24pub struct ScriptLifecycle {
25 compiled: CompiledUi,
26 runtime: Rc<RefCell<UiRuntimeState>>,
27 root_path: ComponentInstancePath,
28 window: Option<String>,
29 view: Option<String>,
30 events: BTreeMap<String, EventSchema>,
31 state: LifecycleState,
32 root: Option<Rc<UiNode>>,
33 retained: crate::RetainedUiTree,
34 semantics: crate::CommittedSemanticFrame,
35 suspended_at: Option<std::time::Instant>,
36}
37
38#[derive(Clone)]
39pub(crate) struct ScriptLifecycleCheckpoint {
40 compiled: CompiledUi,
41 state: LifecycleState,
42 root: Option<Rc<UiNode>>,
43 retained: crate::RetainedUiTree,
44 semantics: crate::CommittedSemanticFrame,
45 suspended_at: Option<std::time::Instant>,
46}
47
48#[derive(Default)]
49struct RetainedDeclarations {
50 effects: BTreeMap<crate::EffectId, crate::EffectDescriptor>,
51 timers: BTreeMap<crate::TimerId, crate::TimerDescriptor>,
52 signals: BTreeMap<crate::SignalId, crate::signal::SignalDescriptor>,
53 element_refs: BTreeMap<crate::ElementRefId, crate::NodeId>,
54}
55
56impl ScriptLifecycle {
57 pub(crate) fn execution_checkpoint(&self) -> ScriptLifecycleCheckpoint {
58 ScriptLifecycleCheckpoint {
59 compiled: self.compiled.clone(),
60 state: self.state,
61 root: self.root.clone(),
62 retained: self.retained.clone(),
63 semantics: self.semantics.clone(),
64 suspended_at: self.suspended_at,
65 }
66 }
67
68 pub(crate) fn restore_execution_checkpoint(&mut self, checkpoint: ScriptLifecycleCheckpoint) {
69 self.compiled = checkpoint.compiled;
70 self.state = checkpoint.state;
71 self.root = checkpoint.root;
72 self.retained = checkpoint.retained;
73 self.semantics = checkpoint.semantics;
74 self.suspended_at = checkpoint.suspended_at;
75 }
76
77 pub fn new(
84 compiled: CompiledUi,
85 runtime: Rc<RefCell<UiRuntimeState>>,
86 root_path: ComponentInstancePath,
87 window: Option<String>,
88 events: BTreeMap<String, EventSchema>,
89 state_schema: &ComponentStateSchema,
90 ) -> Result<Self, LifecycleError> {
91 {
92 let mut runtime_state = runtime
93 .try_borrow_mut()
94 .map_err(|_| LifecycleError::Borrowed)?;
95 let mut transaction = runtime_state
96 .component_state
97 .begin_render_scope(root_path.clone());
98 transaction.mount(root_path.clone(), state_schema)?;
99 runtime_state.component_state.commit_render(transaction);
100 runtime_state.ensure_component_incarnation(&root_path);
101 }
102 Ok(Self {
103 compiled,
104 runtime,
105 root_path,
106 window,
107 view: None,
108 events,
109 state: LifecycleState::Created,
110 root: None,
111 retained: crate::RetainedUiTree::new(),
112 semantics: crate::CommittedSemanticFrame::default(),
113 suspended_at: None,
114 })
115 }
116
117 #[must_use]
118 pub const fn state(&self) -> LifecycleState {
119 self.state
120 }
121
122 #[must_use]
123 pub fn root(&self) -> Option<&UiNode> {
124 self.root.as_deref()
125 }
126
127 #[must_use]
128 pub const fn retained(&self) -> &crate::RetainedUiTree {
129 &self.retained
130 }
131
132 #[must_use]
133 pub const fn semantics(&self) -> &crate::CommittedSemanticFrame {
134 &self.semantics
135 }
136
137 #[must_use]
138 pub fn generation(&self) -> ScriptGeneration {
139 self.compiled.generation()
140 }
141
142 #[must_use]
143 pub fn runtime(&self) -> Rc<RefCell<UiRuntimeState>> {
144 Rc::clone(&self.runtime)
145 }
146
147 #[must_use]
148 pub fn with_view_id(mut self, view: impl Into<String>) -> Self {
149 let view = view.into();
150 if let Ok(mut runtime) = self.runtime.try_borrow_mut() {
151 runtime.ensure_presentation(&view);
152 }
153 self.view = Some(view);
154 self
155 }
156
157 pub fn initialize(&mut self, engine: &RuntimeEngine) -> Result<(), LifecycleError> {
164 self.require_state(LifecycleState::Created)?;
165 let snapshot = self.begin_runtime_transaction()?;
166 let context = self.context(ExecutionPhase::Init);
167 match engine.call_optional_lifecycle(&self.compiled, "init", context) {
168 Ok(_) => {
169 self.state = LifecycleState::Initialized;
170 self.commit_runtime_transaction()
171 }
172 Err(error) => {
173 self.runtime
174 .try_borrow_mut()
175 .map_err(|_| LifecycleError::Borrowed)?
176 .restore(snapshot)?;
177 Err(error.into())
178 }
179 }
180 }
181
182 pub fn validate_initial_view_without_init(
193 &mut self,
194 engine: &mut RuntimeEngine,
195 ) -> Result<&UiNode, LifecycleError> {
196 self.require_state(LifecycleState::Created)?;
197 self.state = LifecycleState::Initialized;
198 self.render(engine)
199 }
200
201 pub fn render(&mut self, engine: &mut RuntimeEngine) -> Result<&UiNode, LifecycleError> {
208 self.render_impl(engine, None)
209 }
210
211 fn render_impl(
212 &mut self,
213 engine: &mut RuntimeEngine,
214 reuse_dirty: Option<&BTreeSet<ComponentInstancePath>>,
215 ) -> Result<&UiNode, LifecycleError> {
216 if !matches!(
217 self.state,
218 LifecycleState::Initialized | LifecycleState::Running
219 ) {
220 return Err(LifecycleError::InvalidTransition {
221 from: self.state,
222 operation: "render",
223 });
224 }
225 let runtime_snapshot = self.begin_runtime_transaction()?;
226 let engine_checkpoint = engine.execution_checkpoint();
227 let previous_root = reuse_dirty.and_then(|_| self.root.clone());
228 let result = (|| {
229 let context = self.context(ExecutionPhase::Render);
230 let root = match (previous_root.as_ref(), reuse_dirty) {
231 (Some(previous_root), Some(dirty)) => engine.render_with_context_staged_reusing(
232 &self.compiled,
233 context,
234 Rc::clone(previous_root),
235 dirty,
236 )?,
237 _ => engine.render_with_context_staged(&self.compiled, context)?,
238 };
239 let mut retained = self.retained.clone();
240 let motion_reconcile_report = retained.reconcile(root.clone())?;
241 self.validate_resource_budgets(engine, &retained, &root)?;
242 self.reconcile_motion(&root, &retained, self.compiled.generation())?;
243 self.reconcile_exit_ghosts(&motion_reconcile_report)?;
244 self.reconcile_effects(
245 engine,
246 &self.compiled,
247 runtime_snapshot.component_state().clone(),
248 &retained,
249 &root,
250 )?;
251 self.retain_geometry_nodes(&retained)?;
252 self.validate_signal_bindings(&root)?;
253 let semantics = crate::CommittedSemanticFrame::from_retained(&retained)?;
254 Ok((root, retained, semantics))
255 })();
256 match result {
257 Ok((root, retained, semantics)) => {
258 self.trace_reconcile("full", retained.last_report());
259 self.retained = retained;
260 self.semantics = semantics;
261 self.state = LifecycleState::Running;
262 self.root = Some(Rc::new(root));
263 self.commit_runtime_transaction()?;
264 self.root.as_deref().ok_or(LifecycleError::MissingRoot)
265 }
266 Err(error) => {
267 self.runtime
268 .try_borrow_mut()
269 .map_err(|_| LifecycleError::Borrowed)?
270 .restore(runtime_snapshot)?;
271 engine.restore_execution_checkpoint(engine_checkpoint);
272 Err(error)
273 }
274 }
275 }
276
277 pub fn render_dirty(&mut self, engine: &mut RuntimeEngine) -> Result<bool, LifecycleError> {
287 if !self
288 .runtime
289 .try_borrow()
290 .map_err(|_| LifecycleError::Borrowed)?
291 .has_window_dirty(&self.root_path)
292 {
293 return Ok(false);
294 }
295 let runtime_snapshot = self.begin_runtime_transaction()?;
296 let engine_checkpoint = engine.execution_checkpoint();
297 let dirty = self
298 .runtime
299 .try_borrow_mut()
300 .map_err(|_| LifecycleError::Borrowed)?
301 .take_window_dirty_components(&self.root_path);
302 debug_assert!(!dirty.is_empty());
303 let topmost = topmost_paths(&dirty);
304 if topmost.iter().any(|path| path == &self.root_path)
305 || topmost.iter().any(|path| {
306 engine
307 .component_invocations()
308 .all(|recipe| recipe.path() != path)
309 })
310 {
311 return self.render_dirty_full(engine, &dirty, runtime_snapshot, engine_checkpoint);
312 }
313
314 let mut root = self
315 .root
316 .as_deref()
317 .cloned()
318 .ok_or(LifecycleError::MissingRoot)?;
319 let active = engine
320 .component_invocations()
321 .map(|recipe| recipe.path().clone())
322 .collect::<BTreeSet<_>>();
323 let Some(topmost) = replaceable_component_paths(&root, &self.root_path, topmost, &active)
324 else {
325 return self.render_dirty_full(engine, &dirty, runtime_snapshot, engine_checkpoint);
326 };
327 engine.begin_execution_session();
328 let mut retained = self.retained.clone();
329 let result = (|| {
330 for path in topmost {
331 let subtree = engine.rerender_component(&path, &root, &dirty)?;
332 if !root.replace_component_subtree(&path, subtree) {
333 return Err(LifecycleError::MissingComponentSubtree(path));
334 }
335 }
336 let motion_reconcile_report = retained.reconcile(root.clone())?;
337 self.validate_resource_budgets(engine, &retained, &root)?;
338 self.reconcile_motion(&root, &retained, self.compiled.generation())?;
339 self.reconcile_exit_ghosts(&motion_reconcile_report)?;
340 self.reconcile_effects(
341 engine,
342 &self.compiled,
343 runtime_snapshot.component_state().clone(),
344 &retained,
345 &root,
346 )?;
347 self.retain_geometry_nodes(&retained)?;
348 self.validate_signal_bindings(&root)?;
349 Ok(crate::CommittedSemanticFrame::from_retained(&retained)?)
350 })();
351 match result {
352 Ok(semantics) => {
353 self.trace_reconcile("incremental", retained.last_report());
354 self.root = Some(Rc::new(root));
355 self.retained = retained;
356 self.semantics = semantics;
357 self.state = LifecycleState::Running;
358 self.commit_runtime_transaction()?;
359 Ok(true)
360 }
361 Err(error) => {
362 self.runtime
363 .try_borrow_mut()
364 .map_err(|_| LifecycleError::Borrowed)?
365 .restore(runtime_snapshot)?;
366 engine.restore_execution_checkpoint(engine_checkpoint);
367 Err(error)
368 }
369 }
370 }
371
372 fn render_dirty_full(
373 &mut self,
374 engine: &mut RuntimeEngine,
375 dirty: &BTreeSet<ComponentInstancePath>,
376 runtime_snapshot: crate::UiStateSnapshot,
377 engine_checkpoint: crate::engine::RuntimeEngineCheckpoint,
378 ) -> Result<bool, LifecycleError> {
379 match self.render_impl(engine, Some(dirty)) {
380 Ok(_) => {
381 self.commit_runtime_transaction()?;
382 Ok(true)
383 }
384 Err(error) => {
385 self.runtime
386 .try_borrow_mut()
387 .map_err(|_| LifecycleError::Borrowed)?
388 .restore(runtime_snapshot)?;
389 engine.restore_execution_checkpoint(engine_checkpoint);
390 Err(error)
391 }
392 }
393 }
394
395 #[allow(clippy::too_many_lines)]
401 pub fn realize_virtual_requests(
402 &mut self,
403 engine: &mut RuntimeEngine,
404 ) -> Result<bool, LifecycleError> {
405 if !self
406 .runtime
407 .try_borrow()
408 .map_err(|_| LifecycleError::Borrowed)?
409 .virtual_requests
410 .has_scope(&self.root_path)
411 {
412 return Ok(false);
413 }
414 let runtime_snapshot = self.begin_runtime_transaction()?;
415 let engine_checkpoint = engine.execution_checkpoint();
416 let requests = self
417 .runtime
418 .try_borrow()
419 .map_err(|_| LifecycleError::Borrowed)?
420 .virtual_requests
421 .drain();
422 let mut selected = Vec::new();
423 for (id, indices) in requests {
424 if id.component.is_within(&self.root_path) {
425 selected.push((id, indices));
426 } else {
427 self.runtime
428 .try_borrow()
429 .map_err(|_| LifecycleError::Borrowed)?
430 .virtual_requests
431 .request(id, indices);
432 }
433 }
434 if selected.is_empty() {
435 return Ok(false);
436 }
437 engine.begin_execution_session();
438 let mut root = self
439 .root
440 .as_deref()
441 .cloned()
442 .ok_or(LifecycleError::MissingRoot)?;
443 let mut retained = self.retained.clone();
444 let result = (|| {
445 let mut changed = false;
446 for (id, indices) in selected {
447 if root.virtual_collection_spec(&id).is_none()
450 && self
451 .root
452 .as_deref()
453 .is_some_and(|root| root.virtual_collection_spec(&id).is_some())
454 {
455 continue;
456 }
457 let mut items = root
458 .virtual_collection_items(&id)
459 .cloned()
460 .ok_or_else(|| LifecycleError::MissingVirtualCollection(id.clone()))?;
461 let previous_indices = items.keys().copied().collect::<BTreeSet<_>>();
462 if previous_indices == indices {
463 continue;
464 }
465 let inherited_motion_group = root
466 .virtual_collection_spec(&id)
467 .and_then(|spec| spec.inherited_motion_group.clone());
468 items = engine.realize_virtual_collection(&id, &indices, &items)?;
469 if let Some(group) = inherited_motion_group {
470 for node in items.values_mut() {
471 crate::node::apply_motion_group(node, &group);
472 }
473 }
474 if !root.replace_virtual_collection_items(&id, items.clone()) {
475 return Err(LifecycleError::MissingVirtualCollection(id));
476 }
477 engine.update_virtual_collection_snapshot(&id, &items);
478 changed = true;
479 }
480 if !changed {
481 return Ok((false, self.semantics.clone()));
482 }
483 let motion_reconcile_report = retained.reconcile(root.clone())?;
484 self.validate_resource_budgets(engine, &retained, &root)?;
485 self.reconcile_motion(&root, &retained, self.compiled.generation())?;
486 self.reconcile_exit_ghosts(&motion_reconcile_report)?;
487 self.reconcile_effects(
488 engine,
489 &self.compiled,
490 runtime_snapshot.component_state().clone(),
491 &retained,
492 &root,
493 )?;
494 self.retain_geometry_nodes(&retained)?;
495 self.validate_signal_bindings(&root)?;
496 Ok((
497 true,
498 crate::CommittedSemanticFrame::from_retained(&retained)?,
499 ))
500 })();
501 match result {
502 Ok((changed, semantics)) => {
503 if changed {
504 self.trace_reconcile("virtual", retained.last_report());
505 self.root = Some(Rc::new(root));
506 self.retained = retained;
507 self.semantics = semantics;
508 }
509 self.commit_runtime_transaction()?;
510 Ok(changed)
511 }
512 Err(error) => {
513 self.runtime
514 .try_borrow_mut()
515 .map_err(|_| LifecycleError::Borrowed)?
516 .restore(runtime_snapshot)?;
517 engine.restore_execution_checkpoint(engine_checkpoint);
518 Err(error)
519 }
520 }
521 }
522
523 pub fn dispose(&mut self, engine: &mut RuntimeEngine) -> Result<(), LifecycleError> {
530 if !matches!(
531 self.state,
532 LifecycleState::Initialized | LifecycleState::Running | LifecycleState::Suspended
533 ) {
534 return Err(LifecycleError::InvalidTransition {
535 from: self.state,
536 operation: "dispose",
537 });
538 }
539 let snapshot = self.begin_runtime_transaction()?;
540 let result = (|| {
541 self.reconcile_effect_candidate(
542 engine,
543 &self.compiled,
544 RetainedDeclarations::default(),
545 None,
546 )?;
547 let context = self.context(ExecutionPhase::Dispose);
548 engine.call_optional_lifecycle(&self.compiled, "dispose", context)?;
549 Ok(())
550 })();
551 match result {
552 Ok(()) => {
553 self.state = LifecycleState::Disposed;
554 self.commit_runtime_transaction()?;
555 Ok(())
556 }
557 Err(error) => {
558 self.runtime
559 .try_borrow_mut()
560 .map_err(|_| LifecycleError::Borrowed)?
561 .restore(snapshot)?;
562 Err(error)
563 }
564 }
565 }
566
567 pub fn suspend(&mut self, engine: &mut RuntimeEngine) -> Result<bool, LifecycleError> {
575 if self.state == LifecycleState::Suspended {
576 return Ok(false);
577 }
578 if self.state != LifecycleState::Running {
579 return Err(LifecycleError::InvalidTransition {
580 from: self.state,
581 operation: "suspend",
582 });
583 }
584 let snapshot = self.begin_runtime_transaction()?;
585 let checkpoint = engine.execution_checkpoint();
586 let result = (|| {
587 engine.call_optional_lifecycle(
588 &self.compiled,
589 "suspend",
590 self.context(ExecutionPhase::Suspend),
591 )?;
592 self.stop_active_effects(engine)?;
593 let mut runtime = self
594 .runtime
595 .try_borrow_mut()
596 .map_err(|_| LifecycleError::Borrowed)?;
597 let now = runtime.clock.now();
598 runtime.timers.pause_component_scope(&self.root_path, now);
599 runtime
600 .motions
601 .suspend_node_scope(&self.motion_root_path(), now);
602 runtime.motion_values = runtime.motions.snapshot(now);
603 runtime
604 .pointer_capture_for(self.presentation_scope())
605 .clear();
606 Ok(now)
607 })();
608 match result {
609 Ok(now) => {
610 self.suspended_at = Some(now);
611 self.state = LifecycleState::Suspended;
612 self.commit_runtime_transaction()?;
613 Ok(true)
614 }
615 Err(error) => {
616 self.runtime
617 .try_borrow_mut()
618 .map_err(|_| LifecycleError::Borrowed)?
619 .restore(snapshot)?;
620 engine.restore_execution_checkpoint(checkpoint);
621 Err(error)
622 }
623 }
624 }
625
626 pub fn resume(&mut self, engine: &mut RuntimeEngine) -> Result<bool, LifecycleError> {
635 if self.state == LifecycleState::Running {
636 return Ok(false);
637 }
638 if self.state != LifecycleState::Suspended {
639 return Err(LifecycleError::InvalidTransition {
640 from: self.state,
641 operation: "resume",
642 });
643 }
644 let snapshot = self.begin_runtime_transaction()?;
645 let checkpoint = engine.execution_checkpoint();
646 let suspended_at = self
647 .suspended_at
648 .ok_or(LifecycleError::MissingSuspendTime)?;
649 let now = self
650 .runtime
651 .try_borrow()
652 .map_err(|_| LifecycleError::Borrowed)?
653 .clock
654 .now();
655 let elapsed = now.saturating_duration_since(suspended_at);
656 let result = (|| {
657 {
658 self.resume_runtime_mechanisms(now, elapsed)?;
659 }
660 engine.call_optional_lifecycle_with_value(
661 &self.compiled,
662 "resume",
663 self.context(ExecutionPhase::Resume),
664 UiValue::Integer(i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)),
665 )?;
666 self.state = LifecycleState::Initialized;
667 self.render_impl(engine, None)?;
668 Ok(())
669 })();
670 match result {
671 Ok(()) => {
672 self.suspended_at = None;
673 self.state = LifecycleState::Running;
674 self.commit_runtime_transaction()?;
675 Ok(true)
676 }
677 Err(error) => {
678 self.state = LifecycleState::Suspended;
679 self.runtime
680 .try_borrow_mut()
681 .map_err(|_| LifecycleError::Borrowed)?
682 .restore(snapshot)?;
683 engine.restore_execution_checkpoint(checkpoint);
684 Err(error)
685 }
686 }
687 }
688
689 fn stop_active_effects(&self, engine: &RuntimeEngine) -> Result<(), LifecycleError> {
690 let plan = self
691 .runtime
692 .try_borrow()
693 .map_err(|_| LifecycleError::Borrowed)?
694 .effects
695 .plan(&self.root_path, BTreeMap::new());
696 if plan.transition_count() > 64 {
697 return Err(LifecycleError::EffectBudget(plan.transition_count()));
698 }
699 for (descriptor, scope) in plan.cleanup_descriptors() {
700 self.invoke_effect_callback(
701 engine,
702 &self.compiled,
703 descriptor.cleanup(),
704 descriptor.dependencies().clone(),
705 scope,
706 )?;
707 }
708 let mut runtime = self
709 .runtime
710 .try_borrow_mut()
711 .map_err(|_| LifecycleError::Borrowed)?;
712 for (_, scope) in plan.cleanup_descriptors() {
713 runtime.cancel_async_scope(&scope)?;
714 }
715 runtime.effects.commit(plan);
716 Ok(())
717 }
718
719 pub fn invoke_callback(
725 &self,
726 engine: &RuntimeEngine,
727 callback: &ScriptCallback,
728 payload: UiValue,
729 ) -> Result<Dynamic, LifecycleError> {
730 self.invoke_callback_with_event_target(engine, callback, payload, None)
731 }
732
733 pub(crate) fn invoke_callback_with_event_target(
734 &self,
735 engine: &RuntimeEngine,
736 callback: &ScriptCallback,
737 payload: UiValue,
738 event_target: Option<crate::GeometryBounds>,
739 ) -> Result<Dynamic, LifecycleError> {
740 self.validate_callback_owner(callback)?;
741 let root_context = self.context(ExecutionPhase::Event);
742 let context = callback
743 .component()
744 .map_or(root_context.clone(), |component| {
745 root_context.for_component(component.clone(), callback.events().clone())
746 })
747 .with_native_context(callback.native_context().cloned())
748 .with_event_target(event_target);
749 Ok(engine.invoke_callback(&self.compiled, callback, (context, payload.into_dynamic()))?)
750 }
751
752 pub fn invoke_callback_transactional(
759 &self,
760 engine: &RuntimeEngine,
761 callback: &ScriptCallback,
762 payload: UiValue,
763 ) -> Result<Dynamic, LifecycleError> {
764 let snapshot = self.begin_runtime_transaction()?;
765 match self.invoke_callback(engine, callback, payload) {
766 Ok(value) => {
767 self.commit_runtime_transaction()?;
768 Ok(value)
769 }
770 Err(error) => {
771 self.runtime
772 .try_borrow_mut()
773 .map_err(|_| LifecycleError::Borrowed)?
774 .restore(snapshot)?;
775 Err(error)
776 }
777 }
778 }
779
780 pub fn invoke_async_delivery(
786 &self,
787 engine: &RuntimeEngine,
788 delivery: AsyncDelivery,
789 ) -> Result<Dynamic, LifecycleError> {
790 if !self
791 .runtime
792 .try_borrow()
793 .map_err(|_| LifecycleError::Borrowed)?
794 .effects
795 .contains_scope(&delivery.scope)
796 {
797 return Ok(Dynamic::UNIT);
798 }
799 self.validate_callback_owner(&delivery.callback)?;
800 let component = delivery
801 .callback
802 .component()
803 .cloned()
804 .or_else(|| delivery.scope.component().cloned())
805 .unwrap_or_else(|| self.root_path.clone());
806 let events = delivery.callback.events().clone();
807 let scope = delivery.scope.clone();
808 let context = UiContext::new(
809 Rc::clone(&self.runtime),
810 component,
811 self.window.clone(),
812 ExecutionPhase::Event,
813 events,
814 )
815 .with_optional_view_id(self.view.clone())
816 .with_generation(self.compiled.generation())
817 .with_native_context(delivery.callback.native_context().cloned())
818 .with_async_scope(scope);
819 Ok(engine.invoke_callback(
820 &self.compiled,
821 &delivery.callback,
822 (context, delivery.payload.into_dynamic()),
823 )?)
824 }
825
826 pub fn invoke_async_delivery_transactional(
832 &self,
833 engine: &RuntimeEngine,
834 delivery: AsyncDelivery,
835 ) -> Result<Dynamic, LifecycleError> {
836 let snapshot = self.begin_runtime_transaction()?;
837 match self.invoke_async_delivery(engine, delivery) {
838 Ok(value) => {
839 self.commit_runtime_transaction()?;
840 Ok(value)
841 }
842 Err(error) => {
843 self.runtime
844 .try_borrow_mut()
845 .map_err(|_| LifecycleError::Borrowed)?
846 .restore(snapshot)?;
847 Err(error)
848 }
849 }
850 }
851
852 pub fn invoke_component_event_transactional(
859 &self,
860 engine: &RuntimeEngine,
861 pending: crate::PendingEvent,
862 ) -> Result<Option<Dynamic>, LifecycleError> {
863 let callback = self
864 .runtime
865 .try_borrow()
866 .map_err(|_| LifecycleError::Borrowed)?
867 .component_event_handler(&pending.target, &pending.event.name);
868 callback
869 .map(|callback| {
870 self.invoke_callback_transactional(engine, &callback, pending.event.payload)
871 })
872 .transpose()
873 }
874
875 pub fn reload(
884 &mut self,
885 engine: &mut RuntimeEngine,
886 candidate: CompiledUi,
887 state_schema: &ComponentStateSchema,
888 ) -> Result<&UiNode, LifecycleError> {
889 if self.state != LifecycleState::Running {
890 return Err(LifecycleError::InvalidTransition {
891 from: self.state,
892 operation: "reload",
893 });
894 }
895 let snapshot = self.begin_runtime_transaction()?;
896 let engine_checkpoint = engine.execution_checkpoint();
897 let result: Result<
898 (UiNode, crate::RetainedUiTree, crate::CommittedSemanticFrame),
899 LifecycleError,
900 > = (|| {
901 self.runtime
902 .try_borrow_mut()
903 .map_err(|_| LifecycleError::Borrowed)?
904 .component_state
905 .mount_instance(self.root_path.clone(), state_schema)?;
906 engine.call_optional_lifecycle(
907 &candidate,
908 "init",
909 self.context_for(ExecutionPhase::Init, candidate.generation()),
910 )?;
911 let root = engine.render_with_context_staged(
912 &candidate,
913 self.context_for(ExecutionPhase::Render, candidate.generation()),
914 )?;
915 let mut retained = self.retained.clone();
916 let motion_reconcile_report = retained.reconcile(root.clone())?;
917 self.validate_resource_budgets(engine, &retained, &root)?;
918 self.reconcile_motion(&root, &retained, candidate.generation())?;
919 self.reconcile_exit_ghosts(&motion_reconcile_report)?;
920 self.reconcile_effects(
921 engine,
922 &candidate,
923 snapshot.component_state().clone(),
924 &retained,
925 &root,
926 )?;
927 self.retain_geometry_nodes(&retained)?;
928 self.validate_signal_bindings(&root)?;
929 let semantics = crate::CommittedSemanticFrame::from_retained(&retained)?;
930 Ok((root, retained, semantics))
931 })();
932 match result {
933 Ok((root, retained, semantics)) => {
934 self.trace_reconcile("reload", retained.last_report());
935 self.compiled = candidate;
936 self.retained = retained;
937 self.semantics = semantics;
938 self.state = LifecycleState::Running;
939 self.root = Some(Rc::new(root));
940 self.commit_runtime_transaction()?;
941 self.root.as_deref().ok_or(LifecycleError::MissingRoot)
942 }
943 Err(error) => {
944 self.runtime
945 .try_borrow_mut()
946 .map_err(|_| LifecycleError::Borrowed)?
947 .restore(snapshot)?;
948 engine.restore_execution_checkpoint(engine_checkpoint);
949 Err(error)
950 }
951 }
952 }
953
954 pub fn resume_reload(
965 &mut self,
966 engine: &mut RuntimeEngine,
967 candidate: CompiledUi,
968 state_schema: &ComponentStateSchema,
969 ) -> Result<&UiNode, LifecycleError> {
970 if self.state != LifecycleState::Suspended {
971 return Err(LifecycleError::InvalidTransition {
972 from: self.state,
973 operation: "resume_reload",
974 });
975 }
976 let snapshot = self.begin_runtime_transaction()?;
977 let engine_checkpoint = engine.execution_checkpoint();
978 let suspended_at = self
979 .suspended_at
980 .ok_or(LifecycleError::MissingSuspendTime)?;
981 let now = self
982 .runtime
983 .try_borrow()
984 .map_err(|_| LifecycleError::Borrowed)?
985 .clock
986 .now();
987 let elapsed = now.saturating_duration_since(suspended_at);
988 let result: Result<
989 (UiNode, crate::RetainedUiTree, crate::CommittedSemanticFrame),
990 LifecycleError,
991 > = (|| {
992 self.runtime
993 .try_borrow_mut()
994 .map_err(|_| LifecycleError::Borrowed)?
995 .component_state
996 .mount_instance(self.root_path.clone(), state_schema)?;
997 engine.call_optional_lifecycle(
998 &candidate,
999 "init",
1000 self.context_for(ExecutionPhase::Init, candidate.generation()),
1001 )?;
1002 engine.call_optional_lifecycle_with_value(
1003 &candidate,
1004 "resume",
1005 self.context_for(ExecutionPhase::Resume, candidate.generation()),
1006 UiValue::Integer(i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)),
1007 )?;
1008 {
1009 let mut runtime = self
1010 .runtime
1011 .try_borrow_mut()
1012 .map_err(|_| LifecycleError::Borrowed)?;
1013 runtime.motion_values = runtime.motions.snapshot(now);
1014 }
1015 let root = engine.render_with_context_staged(
1016 &candidate,
1017 self.context_for(ExecutionPhase::Render, candidate.generation()),
1018 )?;
1019 let mut retained = self.retained.clone();
1020 let motion_reconcile_report = retained.reconcile(root.clone())?;
1021 self.validate_resource_budgets(engine, &retained, &root)?;
1022 self.reconcile_motion(&root, &retained, candidate.generation())?;
1023 self.reconcile_exit_ghosts(&motion_reconcile_report)?;
1024 self.reconcile_effects(
1025 engine,
1026 &candidate,
1027 snapshot.component_state().clone(),
1028 &retained,
1029 &root,
1030 )?;
1031 self.resume_runtime_mechanisms(now, elapsed)?;
1032 self.retain_geometry_nodes(&retained)?;
1033 self.validate_signal_bindings(&root)?;
1034 let semantics = crate::CommittedSemanticFrame::from_retained(&retained)?;
1035 Ok((root, retained, semantics))
1036 })();
1037 match result {
1038 Ok((root, retained, semantics)) => {
1039 self.trace_reconcile("resume_reload", retained.last_report());
1040 self.compiled = candidate;
1041 self.retained = retained;
1042 self.semantics = semantics;
1043 self.root = Some(Rc::new(root));
1044 self.suspended_at = None;
1045 self.state = LifecycleState::Running;
1046 self.commit_runtime_transaction()?;
1047 self.root.as_deref().ok_or(LifecycleError::MissingRoot)
1048 }
1049 Err(error) => {
1050 self.runtime
1051 .try_borrow_mut()
1052 .map_err(|_| LifecycleError::Borrowed)?
1053 .restore(snapshot)?;
1054 engine.restore_execution_checkpoint(engine_checkpoint);
1055 self.state = LifecycleState::Suspended;
1056 Err(error)
1057 }
1058 }
1059 }
1060
1061 pub fn start(&mut self, engine: &mut RuntimeEngine) -> Result<&UiNode, LifecycleError> {
1067 let runtime_snapshot = self.begin_runtime_transaction()?;
1068 let engine_checkpoint = engine.execution_checkpoint();
1069 let lifecycle_checkpoint = self.execution_checkpoint();
1070 let result = (|| {
1071 self.initialize(engine)?;
1072 self.render(engine)?;
1073 Ok::<_, LifecycleError>(())
1074 })();
1075 match result {
1076 Ok(()) => {
1077 self.commit_runtime_transaction()?;
1078 self.root.as_deref().ok_or(LifecycleError::MissingRoot)
1079 }
1080 Err(error) => {
1081 self.runtime
1082 .try_borrow_mut()
1083 .map_err(|_| LifecycleError::Borrowed)?
1084 .restore(runtime_snapshot)?;
1085 engine.restore_execution_checkpoint(engine_checkpoint);
1086 self.restore_execution_checkpoint(lifecycle_checkpoint);
1087 Err(error)
1088 }
1089 }
1090 }
1091
1092 fn context(&self, phase: ExecutionPhase) -> UiContext {
1093 self.context_for(phase, self.compiled.generation())
1094 }
1095
1096 fn reconcile_motion(
1097 &self,
1098 root: &UiNode,
1099 retained: &crate::RetainedUiTree,
1100 generation: ScriptGeneration,
1101 ) -> Result<(), LifecycleError> {
1102 let domain = self.motion_root_path();
1103 let identities = crate::motion::retained_motion_identities(root, retained, &domain)?;
1104 let mut runtime = self
1105 .runtime
1106 .try_borrow_mut()
1107 .map_err(|_| LifecycleError::Borrowed)?;
1108 let now = runtime.clock.now();
1109 let geometry_slots = crate::motion::node_motion_resource_usage(root).geometry_slots;
1110 crate::RuntimeBudgets::check(
1111 "active_motions",
1112 geometry_slots,
1113 runtime.budgets.active_motions,
1114 )?;
1115 let active_limit = runtime.budgets.active_motions;
1116 runtime.motions.set_active_limit(active_limit);
1117 let incarnations = runtime.component_incarnations_snapshot();
1118 let root_incarnation = runtime
1119 .component_incarnation(&self.root_path)
1120 .unwrap_or_else(crate::ComponentIncarnation::unscoped);
1121 let shared_layout_ids = crate::motion::shared_layout_ids(root)?;
1122 runtime
1123 .geometry_for(self.presentation_scope())
1124 .retain_shared_layout_ids(&shared_layout_ids);
1125 runtime
1126 .motions
1127 .set_active_reservation(&domain, geometry_slots);
1128 let values = crate::motion::reconcile_node_motion_scoped_owned(
1129 root,
1130 &mut runtime.motions,
1131 now,
1132 &domain,
1133 crate::motion::MotionReconcileContext {
1134 domain: &domain,
1135 root_component: &self.root_path,
1136 root_incarnation,
1137 generation,
1138 incarnations: &incarnations,
1139 identities: Some(&identities),
1140 },
1141 )?;
1142 let usage = runtime.motions.resource_usage();
1143 let active = usage.active.saturating_add(usage.geometry_slots);
1144 crate::RuntimeBudgets::check("active_motions", active, runtime.budgets.active_motions)?;
1145 runtime.motion_values = values;
1146 Ok(())
1147 }
1148
1149 fn resume_runtime_mechanisms(
1150 &self,
1151 now: std::time::Instant,
1152 elapsed: std::time::Duration,
1153 ) -> Result<(), LifecycleError> {
1154 let mut runtime = self
1155 .runtime
1156 .try_borrow_mut()
1157 .map_err(|_| LifecycleError::Borrowed)?;
1158 runtime.timers.resume_component_scope(&self.root_path, now);
1159 runtime
1160 .motions
1161 .resume_node_scope(&self.motion_root_path(), now);
1162 runtime
1163 .geometry_for(self.presentation_scope())
1164 .delay_motion(elapsed);
1165 runtime.motion_values = runtime.motions.snapshot(now);
1166 Ok(())
1167 }
1168
1169 fn reconcile_exit_ghosts(&self, report: &crate::ReconcileReport) -> Result<(), LifecycleError> {
1170 let Some(previous) = self.root.as_deref() else {
1171 return Ok(());
1172 };
1173 let Some(root_id) = self.retained.root_id() else {
1174 return Ok(());
1175 };
1176 let removed = report.unmounted.iter().copied().collect::<BTreeSet<_>>();
1177 let mut exits = Vec::<(crate::NodeId, &UiNode)>::new();
1178 collect_removed_exit_nodes(previous, root_id, &self.retained, &removed, &mut exits);
1179 if exits.is_empty() {
1180 return Ok(());
1181 }
1182 let mut runtime = self
1183 .runtime
1184 .try_borrow_mut()
1185 .map_err(|_| LifecycleError::Borrowed)?;
1186 let now = runtime.clock.now();
1187 let domain = self.motion_root_path();
1188 let geometry_registry = runtime.geometry_for(self.presentation_scope());
1189 for (retained, node) in exits {
1190 let Some(geometry) = geometry_registry.get(retained) else {
1191 continue;
1192 };
1193 let ghost = node.motion_ghost().ok_or_else(|| {
1194 LifecycleError::Motion(MotionError::UnsupportedExitGhost(format!(
1195 "{:?}",
1196 node.kind_tag()
1197 )))
1198 })?;
1199 let path = format!("{domain}/ghost:{}", retained.get());
1200 if runtime.motion_ghosts.iter().any(|ghost| ghost.path == path) {
1201 continue;
1202 }
1203 for source in node.exit_motions() {
1204 runtime.motions.start_exit(
1205 ComponentInstancePath::root("UiNode", path.clone()),
1206 source.clone(),
1207 now,
1208 )?;
1209 }
1210 if runtime.motions.is_node_scope_active(&path) {
1211 runtime.motion_ghosts.push(crate::motion::MotionGhost {
1212 id: retained.get().to_string(),
1213 node: ghost,
1214 bounds: geometry.visual,
1215 path,
1216 domain: domain.clone(),
1217 });
1218 }
1219 }
1220 crate::RuntimeBudgets::check(
1221 "motion_ghosts",
1222 runtime.motion_ghosts.len(),
1223 runtime.budgets.motion_ghosts,
1224 )?;
1225 Ok(())
1226 }
1227
1228 #[allow(clippy::too_many_lines)]
1229 fn validate_resource_budgets(
1230 &self,
1231 engine: &RuntimeEngine,
1232 retained: &crate::RetainedUiTree,
1233 root: &UiNode,
1234 ) -> Result<(), LifecycleError> {
1235 let budgets = self
1236 .runtime
1237 .try_borrow()
1238 .map_err(|_| LifecycleError::Borrowed)?
1239 .budgets
1240 .clone();
1241 budgets.validate()?;
1242 crate::RuntimeBudgets::check("retained_nodes", retained.len(), budgets.retained_nodes)?;
1243 let handlers = retained.nodes().fold(0usize, |total, node| {
1244 total.saturating_add(node.handler_count())
1245 });
1246 crate::RuntimeBudgets::check("event_handlers", handlers, budgets.event_handlers)?;
1247 let layers = retained
1248 .nodes()
1249 .filter(|node| node.kind() == crate::UiNodeKindTag::Layer)
1250 .count();
1251 crate::RuntimeBudgets::check("layers", layers, budgets.layers)?;
1252 let canvas_scenes = retained
1253 .nodes()
1254 .filter(|node| node.kind() == crate::UiNodeKindTag::Canvas)
1255 .count();
1256 crate::RuntimeBudgets::check("canvas_scenes", canvas_scenes, budgets.canvas_scenes)?;
1257 let canvas_commands = retained.nodes().fold(0usize, |total, node| {
1258 total.saturating_add(node.canvas_command_count())
1259 });
1260 crate::RuntimeBudgets::check("canvas_commands", canvas_commands, budgets.canvas_commands)?;
1261 let virtual_data = retained.nodes().fold(0usize, |total, node| {
1262 total.saturating_add(node.virtual_data_item_count())
1263 });
1264 let virtual_realized = retained.nodes().fold(0usize, |total, node| {
1265 total.saturating_add(node.virtual_realized_item_count())
1266 });
1267 crate::RuntimeBudgets::check(
1268 "virtual_data_items",
1269 virtual_data,
1270 budgets.virtual_data_items,
1271 )?;
1272 crate::RuntimeBudgets::check(
1273 "virtual_realized_items",
1274 virtual_realized,
1275 budgets.virtual_realized_items,
1276 )?;
1277 crate::RuntimeBudgets::check(
1278 "formal_components",
1279 engine.component_invocations().len(),
1280 budgets.formal_components,
1281 )?;
1282 crate::RuntimeBudgets::check(
1283 "effects",
1284 engine.component_effects_in_scope(&self.root_path).len(),
1285 budgets.effects,
1286 )?;
1287 crate::RuntimeBudgets::check(
1288 "signals",
1289 engine.component_signals_in_scope(&self.root_path).len(),
1290 budgets.signals,
1291 )?;
1292 crate::RuntimeBudgets::check(
1293 "element_refs",
1294 engine
1295 .component_element_refs_in_scope(&self.root_path)
1296 .len(),
1297 budgets.element_refs,
1298 )?;
1299 crate::RuntimeBudgets::check(
1300 "timers",
1301 engine.component_timers_in_scope(&self.root_path).len(),
1302 budgets.timers,
1303 )?;
1304 let motion = crate::motion::node_motion_resource_usage(root);
1305 crate::RuntimeBudgets::check(
1306 "motion_declarations",
1307 motion.declarations,
1308 budgets.motion_declarations,
1309 )?;
1310 crate::RuntimeBudgets::check(
1311 "motion_keyframes",
1312 motion.keyframes,
1313 budgets.motion_keyframes,
1314 )?;
1315 crate::RuntimeBudgets::check(
1316 "motion_timelines",
1317 motion.timelines,
1318 budgets.motion_timelines,
1319 )?;
1320 crate::RuntimeBudgets::check(
1321 "motion_timeline_steps",
1322 motion.timeline_steps,
1323 budgets.motion_timeline_steps,
1324 )?;
1325 crate::RuntimeBudgets::check(
1326 "motion_particles",
1327 motion.particles,
1328 budgets.motion_particles,
1329 )?;
1330 crate::RuntimeBudgets::check(
1331 "shared_motion_snapshots",
1332 motion.shared_snapshots,
1333 budgets.shared_motion_snapshots,
1334 )?;
1335 crate::RuntimeBudgets::check(
1336 "active_motions",
1337 motion.geometry_slots,
1338 budgets.active_motions,
1339 )?;
1340 Ok(())
1341 }
1342
1343 fn reconcile_effects(
1344 &self,
1345 engine: &mut RuntimeEngine,
1346 candidate: &CompiledUi,
1347 previous_state: crate::StateStore,
1348 retained: &crate::RetainedUiTree,
1349 root: &UiNode,
1350 ) -> Result<(), LifecycleError> {
1351 let declarations = RetainedDeclarations {
1352 effects: engine.component_effects_in_scope(&self.root_path),
1353 timers: engine.component_timers_in_scope(&self.root_path),
1354 signals: engine.component_signals_in_scope(&self.root_path),
1355 element_refs: self.element_ref_bindings(engine, retained)?,
1356 };
1357 self.runtime
1358 .try_borrow_mut()
1359 .map_err(|_| LifecycleError::Borrowed)?
1360 .retain_virtual_read_contributions(&self.root_path, &root.virtual_read_contributions());
1361 self.reconcile_effect_candidate(engine, candidate, declarations, Some(previous_state))
1362 }
1363
1364 fn reconcile_effect_candidate(
1365 &self,
1366 engine: &mut RuntimeEngine,
1367 candidate: &CompiledUi,
1368 declarations: RetainedDeclarations,
1369 previous_state: Option<crate::StateStore>,
1370 ) -> Result<(), LifecycleError> {
1371 let plan = self
1372 .runtime
1373 .try_borrow()
1374 .map_err(|_| LifecycleError::Borrowed)?
1375 .effects
1376 .plan(&self.root_path, declarations.effects);
1377 let transition_count = plan.transition_count();
1378 if transition_count > 64 {
1379 return Err(LifecycleError::EffectBudget(transition_count));
1380 }
1381 if plan.has_cleanup()
1382 && let Some(previous_state) = previous_state
1383 {
1384 self.runtime
1385 .try_borrow_mut()
1386 .map_err(|_| LifecycleError::Borrowed)?
1387 .component_state = previous_state;
1388 }
1389 for (descriptor, scope) in plan.cleanup_descriptors() {
1390 self.invoke_effect_callback(
1391 engine,
1392 candidate,
1393 descriptor.cleanup(),
1394 descriptor.dependencies().clone(),
1395 scope,
1396 )?;
1397 }
1398 let virtual_collections = engine.virtual_collection_ids_in_scope(&self.root_path);
1399 {
1400 let mut runtime = self
1401 .runtime
1402 .try_borrow_mut()
1403 .map_err(|_| LifecycleError::Borrowed)?;
1404 let now = runtime.clock.now();
1405 engine.commit_component_renders(&mut runtime)?;
1406 runtime
1407 .timers
1408 .reconcile(&self.root_path, declarations.timers, now);
1409 runtime
1410 .signals
1411 .reconcile(&self.root_path, declarations.signals);
1412 runtime
1413 .element_refs
1414 .reconcile(&self.root_path, declarations.element_refs);
1415 let geometry_bindings = runtime.element_refs.geometry_bindings(&self.root_path);
1416 let geometry = runtime.geometry_for(self.presentation_scope());
1417 geometry.sync_ref_readers(&self.root_path, geometry_bindings);
1418 runtime.virtual_requests.retain(&virtual_collections);
1419 }
1420 for (descriptor, scope) in plan.start_descriptors() {
1421 self.invoke_effect_callback(
1422 engine,
1423 candidate,
1424 descriptor.start(),
1425 descriptor.dependencies().clone(),
1426 scope,
1427 )?;
1428 }
1429 {
1430 let mut runtime = self
1431 .runtime
1432 .try_borrow_mut()
1433 .map_err(|_| LifecycleError::Borrowed)?;
1434 for (_, scope) in plan.cleanup_descriptors() {
1435 runtime.cancel_async_scope(&scope)?;
1436 }
1437 runtime.effects.commit(plan);
1438 }
1439 Ok(())
1440 }
1441
1442 fn invoke_effect_callback(
1443 &self,
1444 engine: &RuntimeEngine,
1445 candidate: &CompiledUi,
1446 callback: &ScriptCallback,
1447 dependencies: UiValue,
1448 scope: AsyncScope,
1449 ) -> Result<(), LifecycleError> {
1450 self.validate_callback_owner(callback)?;
1451 let compiled = if callback.generation() == candidate.generation() {
1452 candidate
1453 } else if callback.generation() == self.compiled.generation() {
1454 &self.compiled
1455 } else {
1456 return Err(LifecycleError::StaleEffect {
1457 name: callback.name().to_owned(),
1458 generation: callback.generation(),
1459 });
1460 };
1461 let root_context = self.context_for(ExecutionPhase::Event, compiled.generation());
1462 let context = callback
1463 .component()
1464 .map_or(root_context.clone(), |component| {
1465 root_context.for_component(component.clone(), callback.events().clone())
1466 })
1467 .with_native_context(callback.native_context().cloned())
1468 .with_async_scope(scope);
1469 let _ = engine.invoke_callback_for_generation(
1470 compiled,
1471 callback,
1472 (context, dependencies.into_dynamic()),
1473 )?;
1474 Ok(())
1475 }
1476
1477 fn validate_callback_owner(&self, callback: &ScriptCallback) -> Result<(), LifecycleError> {
1478 let (Some(component), Some(incarnation)) = (callback.component(), callback.incarnation())
1479 else {
1480 return Ok(());
1481 };
1482 let active = self
1483 .runtime
1484 .try_borrow()
1485 .map_err(|_| LifecycleError::Borrowed)?
1486 .component_incarnation(component);
1487 if active == Some(incarnation) {
1488 Ok(())
1489 } else {
1490 Err(RuntimeError::StaleComponentCallback {
1491 name: callback.name().to_owned(),
1492 component: component.clone(),
1493 }
1494 .into())
1495 }
1496 }
1497
1498 fn validate_signal_bindings(&self, root: &UiNode) -> Result<(), LifecycleError> {
1499 let runtime = self
1500 .runtime
1501 .try_borrow()
1502 .map_err(|_| LifecycleError::Borrowed)?;
1503 let mut pending = vec![root];
1504 while let Some(node) = pending.pop() {
1505 for (_, signal) in node.signal_bindings() {
1506 let _ = runtime.signals.read(signal)?;
1507 }
1508 for (_, children) in node.retained_child_groups() {
1509 pending.extend(children);
1510 }
1511 }
1512 Ok(())
1513 }
1514
1515 fn element_ref_bindings(
1516 &self,
1517 engine: &RuntimeEngine,
1518 retained: &crate::RetainedUiTree,
1519 ) -> Result<BTreeMap<crate::ElementRefId, crate::NodeId>, LifecycleError> {
1520 let declared = engine.component_element_refs_in_scope(&self.root_path);
1521 let mut bindings = BTreeMap::new();
1522 for node in retained.nodes() {
1523 let Some(reference) = node.element_ref() else {
1524 continue;
1525 };
1526 if node.key().is_none() {
1527 return Err(crate::ElementRefError::MissingNodeKey(reference.id().clone()).into());
1528 }
1529 if !declared.contains(reference.id()) {
1530 return Err(crate::ElementRefError::Undeclared(reference.id().clone()).into());
1531 }
1532 if bindings.insert(reference.id().clone(), node.id()).is_some() {
1533 return Err(
1534 crate::ElementRefError::DuplicateBinding(reference.id().clone()).into(),
1535 );
1536 }
1537 }
1538 Ok(bindings)
1539 }
1540
1541 fn retain_geometry_nodes(
1542 &self,
1543 retained: &crate::RetainedUiTree,
1544 ) -> Result<(), LifecycleError> {
1545 let nodes = retained.nodes().map(crate::RetainedNode::id).collect();
1546 let runtime = self
1547 .runtime
1548 .try_borrow()
1549 .map_err(|_| LifecycleError::Borrowed)?;
1550 runtime
1551 .geometry_for(self.presentation_scope())
1552 .retain_nodes(&nodes);
1553 runtime
1554 .pointer_capture_for(self.presentation_scope())
1555 .retain_nodes(&nodes);
1556 Ok(())
1557 }
1558
1559 fn presentation_scope(&self) -> Option<&str> {
1560 self.view.as_deref().or(self.window.as_deref())
1561 }
1562
1563 fn begin_runtime_transaction(&self) -> Result<crate::UiStateSnapshot, LifecycleError> {
1564 self.runtime
1565 .try_borrow_mut()
1566 .map_err(|_| LifecycleError::Borrowed)?
1567 .begin_transaction()
1568 .map_err(LifecycleError::from)
1569 }
1570
1571 fn commit_runtime_transaction(&self) -> Result<(), LifecycleError> {
1572 self.runtime
1573 .try_borrow_mut()
1574 .map_err(|_| LifecycleError::Borrowed)?
1575 .commit_transaction()?;
1576 Ok(())
1577 }
1578
1579 #[must_use]
1580 pub fn window_id(&self) -> Option<&str> {
1581 self.window.as_deref()
1582 }
1583
1584 #[must_use]
1585 pub fn view_id(&self) -> Option<&str> {
1586 self.view.as_deref()
1587 }
1588
1589 #[must_use]
1590 pub fn root_path(&self) -> &ComponentInstancePath {
1591 &self.root_path
1592 }
1593
1594 pub(crate) fn diagnostic_context(
1595 &self,
1596 engine: &RuntimeEngine,
1597 component: Option<&ComponentInstancePath>,
1598 ) -> Result<crate::DiagnosticContext, crate::DiagnosticContextError> {
1599 let root_context = self.context(ExecutionPhase::Event);
1600 let component = component
1601 .cloned()
1602 .or_else(|| engine.last_failed_component())
1603 .unwrap_or_else(|| self.root_path.clone());
1604 let key = component.leaf_key().map(str::to_owned);
1605 let context = root_context.for_component(component, BTreeMap::new());
1606 let source = engine.last_failed_timing().map(|timing| timing.source);
1607 crate::DiagnosticContext::capture(engine, &context, source, key)
1608 }
1609
1610 #[must_use]
1611 pub fn compiled(&self) -> CompiledUi {
1612 self.compiled.clone()
1613 }
1614
1615 fn motion_root_path(&self) -> String {
1616 match (self.window.as_deref(), self.view.as_deref()) {
1617 (Some(window), Some(view)) => format!("window:{window}/view:{view}/root"),
1618 (Some(window), None) => format!("window:{window}/root"),
1619 (None, Some(view)) => format!("view:{view}/root"),
1620 (None, None) => "root".to_owned(),
1621 }
1622 }
1623
1624 fn trace_reconcile(&self, operation: &str, report: &crate::ReconcileReport) {
1625 const ID_SAMPLE_LIMIT: usize = 16;
1626
1627 fn ids(values: &[crate::NodeId]) -> crate::UiValue {
1628 crate::UiValue::Array(
1629 values
1630 .iter()
1631 .take(ID_SAMPLE_LIMIT)
1632 .map(|id| crate::UiValue::Integer(i64::try_from(id.get()).unwrap_or(i64::MAX)))
1633 .collect(),
1634 )
1635 }
1636
1637 let metrics = report.metrics();
1638 let truncated = [
1639 &report.mounted,
1640 &report.preserved,
1641 &report.moved,
1642 &report.unmounted,
1643 ]
1644 .into_iter()
1645 .any(|nodes| nodes.len() > ID_SAMPLE_LIMIT);
1646 let payload = crate::UiValue::Map(BTreeMap::from([
1647 ("mounted".to_owned(), ids(&report.mounted)),
1648 ("preserved".to_owned(), ids(&report.preserved)),
1649 ("moved".to_owned(), ids(&report.moved)),
1650 ("unmounted".to_owned(), ids(&report.unmounted)),
1651 ("truncated".to_owned(), crate::UiValue::Bool(truncated)),
1652 ]));
1653 if let Ok(mut runtime) = self.runtime.try_borrow_mut() {
1654 runtime.traces.push(
1655 crate::RuntimeTraceKind::Reconcile,
1656 self.root_path.to_string(),
1657 format!(
1658 "{operation}: mounted={} preserved={} moved={} unmounted={}",
1659 metrics.mounted, metrics.preserved, metrics.moved, metrics.unmounted
1660 ),
1661 Some(payload),
1662 false,
1663 );
1664 }
1665 }
1666
1667 fn context_for(&self, phase: ExecutionPhase, generation: ScriptGeneration) -> UiContext {
1668 UiContext::new(
1669 Rc::clone(&self.runtime),
1670 self.root_path.clone(),
1671 self.window.clone(),
1672 phase,
1673 self.events.clone(),
1674 )
1675 .with_optional_view_id(self.view.clone())
1676 .with_generation(generation)
1677 }
1678
1679 fn require_state(&self, required: LifecycleState) -> Result<(), LifecycleError> {
1680 if self.state == required {
1681 Ok(())
1682 } else {
1683 Err(LifecycleError::InvalidTransition {
1684 from: self.state,
1685 operation: "initialize",
1686 })
1687 }
1688 }
1689}
1690
1691#[derive(Debug, Error)]
1692pub enum LifecycleError {
1693 #[error("cannot {operation} while lifecycle is {from:?}")]
1694 InvalidTransition {
1695 from: LifecycleState,
1696 operation: &'static str,
1697 },
1698 #[error("UI runtime state is already borrowed")]
1699 Borrowed,
1700 #[error(transparent)]
1701 State(#[from] StateError),
1702 #[error(transparent)]
1703 Runtime(#[from] RuntimeError),
1704 #[error(transparent)]
1705 Motion(#[from] MotionError),
1706 #[error(transparent)]
1707 Reconcile(#[from] crate::ReconcileError),
1708 #[error("script lifecycle has no accepted root")]
1709 MissingRoot,
1710 #[error("suspended lifecycle is missing its monotonic suspension timestamp")]
1711 MissingSuspendTime,
1712 #[error("component subtree `{0}` is missing from the accepted UiNode snapshot")]
1713 MissingComponentSubtree(ComponentInstancePath),
1714 #[error("virtual collection `{0:?}` is missing from the accepted UiNode snapshot")]
1715 MissingVirtualCollection(crate::VirtualCollectionId),
1716 #[error("effect transition exceeded the 64-callback budget with {0} callbacks")]
1717 EffectBudget(usize),
1718 #[error("effect callback `{name}` belongs to unavailable generation {generation}")]
1719 StaleEffect {
1720 name: String,
1721 generation: ScriptGeneration,
1722 },
1723 #[error(transparent)]
1724 Asset(#[from] crate::AssetError),
1725 #[error(transparent)]
1726 Transaction(#[from] crate::UiTransactionError),
1727 #[error(transparent)]
1728 Signal(#[from] crate::SignalError),
1729 #[error(transparent)]
1730 ElementRef(#[from] crate::ElementRefError),
1731 #[error(transparent)]
1732 Budget(#[from] crate::RuntimeBudgetError),
1733 #[error(transparent)]
1734 Accessibility(#[from] crate::AccessibilityError),
1735}
1736
1737fn collect_removed_exit_nodes<'a>(
1738 node: &'a UiNode,
1739 id: crate::NodeId,
1740 retained: &crate::RetainedUiTree,
1741 removed: &BTreeSet<crate::NodeId>,
1742 output: &mut Vec<(crate::NodeId, &'a UiNode)>,
1743) {
1744 if removed.contains(&id) && !node.exit_motions().is_empty() {
1745 output.push((id, node));
1746 return;
1747 }
1748 let Some(retained_node) = retained.node(id) else {
1749 return;
1750 };
1751 for (group, children) in node.retained_child_groups() {
1752 let ids = retained_node
1753 .children()
1754 .filter(|child| child.group() == group)
1755 .map(crate::RetainedChildLink::node)
1756 .collect::<Vec<_>>();
1757 for (child, id) in children.into_iter().zip(ids) {
1758 collect_removed_exit_nodes(child, id, retained, removed, output);
1759 }
1760 }
1761}
1762
1763fn replaceable_component_paths(
1764 root: &UiNode,
1765 root_path: &ComponentInstancePath,
1766 paths: Vec<ComponentInstancePath>,
1767 active: &BTreeSet<ComponentInstancePath>,
1768) -> Option<Vec<ComponentInstancePath>> {
1769 let index = crate::node::ComponentSubtreeIndex::new(root);
1770 let mut replaceable = BTreeSet::new();
1771 for path in paths {
1772 let mut candidate = path;
1773 loop {
1774 if active.contains(&candidate) && index.get(root, &candidate).is_some() {
1775 replaceable.insert(candidate);
1776 break;
1777 }
1778 candidate = candidate.parent()?;
1779 if &candidate == root_path {
1780 return None;
1781 }
1782 }
1783 }
1784 Some(topmost_paths(&replaceable))
1785}
1786
1787#[cfg(test)]
1788mod tests {
1789 use super::*;
1790 use crate::{
1791 AssetData, AsyncCapabilityHandler, CapabilityDescriptor, CapabilityId, CapabilityMethod,
1792 ExecutionOperation, InMemoryAssetProvider, OpaqueHandle, StateField,
1793 SubscriptionCapabilityHandler, SubscriptionWork, TaskWork, UiNodeKind, UiValue,
1794 ValueSchema,
1795 };
1796 use semver::{Version, VersionReq};
1797 use std::time::{Duration, Instant};
1798
1799 fn state_schema() -> ComponentStateSchema {
1800 ComponentStateSchema::new(BTreeMap::from([(
1801 "phase".to_owned(),
1802 StateField::new(ValueSchema::string(), UiValue::String("created".to_owned())),
1803 )]))
1804 .unwrap()
1805 }
1806
1807 const STREAM_COMPONENT_APP: &str = r#"
1808 define_component(#{
1809 metadata: #{ id: "test/stream", "export": "StreamProbe", version: "0.1.0",
1810 runtime_api: #{ min_inclusive: 2, max_exclusive: 3 },
1811 dependencies: [], capabilities: #{ "app.stream": "*" } },
1812 schema: #{ props: #{ key: #{ schema: #{ type: "string" }, required: true, sensitive: false } },
1813 state: #{ fields: #{ phase: #{ schema: #{ type: "string" },
1814 "default": #{ type: "string", value: "idle" } } } },
1815 events: #{}, slots: #{}, parts: [], effects: ["watch"] },
1816 render: Fn("render_StreamProbe"),
1817 });
1818 fn start_stream(ctx, deps) {
1819 ctx.start_subscription(
1820 "app.stream", "watch", (),
1821 Fn("received"), Fn("failed"), #{ delivery: "all" }
1822 );
1823 }
1824 fn stop_stream(ctx, deps) { () }
1825 fn received(ctx, value) { ctx.set_state("phase", value); }
1826 fn failed(ctx, error) { ctx.set_state("phase", "failed"); }
1827 fn render_StreamProbe(ctx, props) {
1828 effect("watch", (), Fn("start_stream"), Fn("stop_stream"));
1829 text(ctx.get_state("phase"))
1830 }
1831 fn view(ctx) { render_component("test/stream", #{ key: "stream" }) }
1832 "#;
1833
1834 #[test]
1835 fn lifecycle_order_and_optional_functions_are_enforced() {
1836 let mut engine = RuntimeEngine::new();
1837 let compiled = engine
1838 .compile(
1839 r#"
1840 fn init(ctx) { ctx.set_state("phase", "initialized"); }
1841 fn view(ctx) { text(ctx.get_state("phase")) }
1842 fn dispose(ctx) { ctx.set_state("phase", "disposed"); }
1843 "#,
1844 )
1845 .unwrap();
1846 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
1847 let path = ComponentInstancePath::root("App", "root");
1848 let mut lifecycle = ScriptLifecycle::new(
1849 compiled,
1850 Rc::clone(&runtime),
1851 path.clone(),
1852 Some("main".to_owned()),
1853 BTreeMap::new(),
1854 &state_schema(),
1855 )
1856 .unwrap();
1857
1858 assert!(matches!(
1859 lifecycle.render(&mut engine),
1860 Err(LifecycleError::InvalidTransition { .. })
1861 ));
1862 let root = lifecycle.start(&mut engine).unwrap();
1863 assert!(matches!(
1864 root.kind(),
1865 crate::UiNodeKind::Text { text } if text == "initialized"
1866 ));
1867 assert!(root.source().is_some());
1868 let reconcile = runtime
1869 .borrow()
1870 .traces
1871 .snapshot()
1872 .into_iter()
1873 .find(|trace| trace.kind == crate::RuntimeTraceKind::Reconcile)
1874 .expect("successful initial render must trace retained mutations");
1875 assert!(reconcile.message.contains("full: mounted=1"));
1876 assert!(matches!(reconcile.payload, Some(UiValue::Map(_))));
1877 lifecycle.dispose(&mut engine).unwrap();
1878 assert_eq!(lifecycle.state(), LifecycleState::Disposed);
1879 assert_eq!(
1880 runtime.borrow().component_state.get(&path, "phase"),
1881 Some(&UiValue::String("disposed".to_owned()))
1882 );
1883 assert!(matches!(
1884 lifecycle.dispose(&mut engine),
1885 Err(LifecycleError::InvalidTransition { .. })
1886 ));
1887 }
1888
1889 #[test]
1890 fn lifecycle_motion_uses_the_host_runtime_clock() {
1891 let mut engine = RuntimeEngine::new();
1892 let compiled = engine
1893 .compile(
1894 r#"
1895 fn view(ctx) {
1896 text("clocked")
1897 .with_key("probe")
1898 .motion(motion_transition("width", 0.0, 100.0, #{ duration_ms: 100, easing: "linear" }))
1899 }
1900 "#,
1901 )
1902 .unwrap();
1903 let start = Instant::now();
1904 let manual = crate::ManualRuntimeClock::new(start);
1905 let mut runtime_state = UiRuntimeState::new();
1906 runtime_state.clock = manual.clock();
1907 let runtime = Rc::new(RefCell::new(runtime_state));
1908 let mut lifecycle = ScriptLifecycle::new(
1909 compiled,
1910 Rc::clone(&runtime),
1911 ComponentInstancePath::root("App", "root"),
1912 Some("main".to_owned()),
1913 BTreeMap::new(),
1914 &ComponentStateSchema::default(),
1915 )
1916 .unwrap();
1917
1918 lifecycle.start(&mut engine).unwrap();
1919 assert_eq!(
1920 runtime
1921 .borrow()
1922 .motion_values
1923 .values()
1924 .copied()
1925 .collect::<Vec<_>>(),
1926 vec![0.0]
1927 );
1928
1929 manual.advance(Duration::from_millis(50));
1930 let mut runtime = runtime.borrow_mut();
1931 let now = runtime.clock.now();
1932 let frame = runtime.motions.tick(now);
1933 assert_eq!(
1934 frame.values.values().copied().collect::<Vec<_>>(),
1935 vec![50.0]
1936 );
1937 }
1938
1939 #[test]
1940 fn reload_migrates_timeline_authority_to_the_candidate_generation() {
1941 let source = r#"
1942 fn view(ctx) {
1943 text("x").with_key("x").timeline(motion_timeline("intro",
1944 motion_track(".", motion_transition("opacity", 0.0, 1.0,
1945 #{ duration_ms: 1000, easing: "linear" })), #{}))
1946 }
1947 "#;
1948 let mut engine = RuntimeEngine::new();
1949 let compiled = engine.compile(source).unwrap();
1950 let now = Instant::now();
1951 let manual = crate::ManualRuntimeClock::new(now);
1952 let mut state = UiRuntimeState::new();
1953 state.clock = manual.clock();
1954 let runtime = Rc::new(RefCell::new(state));
1955 let mut lifecycle = ScriptLifecycle::new(
1956 compiled,
1957 Rc::clone(&runtime),
1958 ComponentInstancePath::root("View", "reload"),
1959 Some("window".to_owned()),
1960 BTreeMap::new(),
1961 &ComponentStateSchema::default(),
1962 )
1963 .unwrap()
1964 .with_view_id("reload");
1965 lifecycle.start(&mut engine).unwrap();
1966 let old = lifecycle
1967 .context(ExecutionPhase::Event)
1968 .motion_handle("intro")
1969 .unwrap();
1970 manual.advance(Duration::from_millis(250));
1971 let generation = lifecycle.generation();
1972 let rejected = engine
1973 .compile(r#"fn view(ctx) { throw "reload rejected"; }"#)
1974 .unwrap();
1975 assert!(
1976 lifecycle
1977 .reload(&mut engine, rejected, &ComponentStateSchema::default(),)
1978 .is_err()
1979 );
1980 assert_eq!(lifecycle.generation(), generation);
1981 assert_eq!(
1982 runtime.borrow().motions.timeline_state(&old),
1983 Some(crate::MotionPlaybackState::Playing)
1984 );
1985 let candidate = engine.compile(source).unwrap();
1986 lifecycle
1987 .reload(&mut engine, candidate, &ComponentStateSchema::default())
1988 .unwrap();
1989 let current = lifecycle
1990 .context(ExecutionPhase::Event)
1991 .motion_handle("intro")
1992 .unwrap();
1993 assert_ne!(old, current);
1994 assert_eq!(runtime.borrow().motions.timeline_state(&old), None);
1995 assert_eq!(
1996 runtime.borrow().motions.timeline_state(¤t),
1997 Some(crate::MotionPlaybackState::Playing)
1998 );
1999 assert!(
2000 (runtime
2001 .borrow()
2002 .motions
2003 .snapshot(manual.clock().now())
2004 .values()
2005 .next()
2006 .copied()
2007 .unwrap()
2008 - 0.25)
2009 .abs()
2010 < 0.01
2011 );
2012 }
2013
2014 #[test]
2015 #[allow(clippy::too_many_lines)]
2016 fn resume_reload_unfreezes_motion_at_the_preserved_position() {
2017 let source = r#"
2018 fn view(ctx) {
2019 text("x").with_key("x").motion(motion_transition(
2020 "width", 0.0, 100.0, #{ duration_ms: 1000, easing: "linear" }))
2021 }
2022 "#;
2023 let source_with_new_motion = r#"
2024 fn view(ctx) {
2025 text("x").with_key("x")
2026 .motion(motion_transition("width", 0.0, 100.0,
2027 #{ duration_ms: 1000, easing: "linear" }))
2028 .motion(motion_transition("opacity", 0.0, 1.0,
2029 #{ duration_ms: 1000, easing: "linear" }))
2030 }
2031 "#;
2032 let mut engine = RuntimeEngine::new();
2033 let compiled = engine.compile(source).unwrap();
2034 let now = Instant::now();
2035 let manual = crate::ManualRuntimeClock::new(now);
2036 let mut state = UiRuntimeState::new();
2037 state.clock = manual.clock();
2038 let runtime = Rc::new(RefCell::new(state));
2039 let mut lifecycle = ScriptLifecycle::new(
2040 compiled,
2041 Rc::clone(&runtime),
2042 ComponentInstancePath::root("View", "resume-reload"),
2043 Some("window".to_owned()),
2044 BTreeMap::new(),
2045 &ComponentStateSchema::default(),
2046 )
2047 .unwrap()
2048 .with_view_id("resume-reload");
2049 lifecycle.start(&mut engine).unwrap();
2050 let node = lifecycle.retained().root_id().unwrap();
2051 let geometry = runtime.borrow().geometry_for(Some("resume-reload"));
2052 let old_bounds = crate::GeometryBounds::new(0.0, 0.0, 100.0, 20.0).unwrap();
2053 let target_bounds = crate::GeometryBounds::new(100.0, 0.0, 100.0, 20.0).unwrap();
2054 geometry.update(
2055 node,
2056 crate::ElementGeometry {
2057 layout: old_bounds,
2058 visual: old_bounds,
2059 clip: None,
2060 },
2061 );
2062 let layout_request = |now| crate::geometry::LayoutMotionRequest {
2063 shared: None,
2064 duration: Duration::from_secs(1),
2065 easing: crate::MotionEasing::Linear,
2066 preference: crate::MotionPreference::Normal,
2067 now,
2068 };
2069 let _ = geometry.sample_layout_motion(node, target_bounds, layout_request(now));
2070 let trigger = crate::MotionProgressBinding::new(
2071 crate::MotionProgressDriver::Hover,
2072 crate::MotionSource::Transition(crate::MotionTransition::new(
2073 crate::MotionProperty::Opacity,
2074 0.0,
2075 1.0,
2076 1_000,
2077 )),
2078 )
2079 .unwrap();
2080 geometry.set_motion_trigger(node, crate::MotionProgressDriver::Hover, true);
2081 let _ =
2082 geometry.sample_motion_trigger(node, &trigger, crate::MotionPreference::Normal, now);
2083 manual.advance(Duration::from_millis(200));
2084 let before_layout = geometry.sample_layout_motion(
2085 node,
2086 target_bounds,
2087 layout_request(manual.clock().now()),
2088 );
2089 let before_trigger = geometry.sample_motion_trigger(
2090 node,
2091 &trigger,
2092 crate::MotionPreference::Normal,
2093 manual.clock().now(),
2094 );
2095 lifecycle.suspend(&mut engine).unwrap();
2096 manual.advance(Duration::from_millis(500));
2097 let rejected = engine
2098 .compile(r#"fn view(ctx) { throw "resume reload rejected"; }"#)
2099 .unwrap();
2100 assert!(
2101 lifecycle
2102 .resume_reload(&mut engine, rejected, &ComponentStateSchema::default(),)
2103 .is_err()
2104 );
2105 assert_eq!(lifecycle.state(), LifecycleState::Suspended);
2106 {
2107 let mut runtime = runtime.borrow_mut();
2108 let frame = runtime.motions.tick(manual.clock().now());
2109 assert!(!frame.needs_frame);
2110 assert!((frame.values.values().next().copied().unwrap() - 20.0).abs() < 0.01);
2111 }
2112 let candidate = engine.compile(source_with_new_motion).unwrap();
2113 lifecycle
2114 .resume_reload(&mut engine, candidate, &ComponentStateSchema::default())
2115 .unwrap();
2116 let resumed_layout = geometry.sample_layout_motion(
2117 node,
2118 target_bounds,
2119 layout_request(manual.clock().now()),
2120 );
2121 let resumed_trigger = geometry.sample_motion_trigger(
2122 node,
2123 &trigger,
2124 crate::MotionPreference::Normal,
2125 manual.clock().now(),
2126 );
2127 assert!((resumed_layout.offset_x - before_layout.offset_x).abs() < 0.01);
2128 assert!((resumed_trigger.value - before_trigger.value).abs() < 0.01);
2129 manual.advance(Duration::from_millis(200));
2130 let mut runtime = runtime.borrow_mut();
2131 let frame = runtime.motions.tick(manual.clock().now());
2132 assert!(frame.needs_frame);
2133 assert!(frame.values.iter().any(|(key, value)| {
2134 key.property == crate::MotionProperty::Width && (*value - 40.0).abs() < 0.01
2135 }));
2136 assert!(frame.values.iter().any(|(key, value)| {
2137 key.property == crate::MotionProperty::Opacity && (*value - 0.2).abs() < 0.01
2138 }));
2139 drop(runtime);
2140 let advanced_layout = geometry.sample_layout_motion(
2141 node,
2142 target_bounds,
2143 layout_request(manual.clock().now()),
2144 );
2145 let advanced_trigger = geometry.sample_motion_trigger(
2146 node,
2147 &trigger,
2148 crate::MotionPreference::Normal,
2149 manual.clock().now(),
2150 );
2151 assert!(advanced_layout.offset_x > resumed_layout.offset_x);
2152 assert!(advanced_trigger.value > resumed_trigger.value);
2153 }
2154
2155 #[test]
2156 fn retained_root_remount_allocates_new_motion_identity() {
2157 let source = r#"
2158 fn view(ctx) {
2159 text("card").with_key(if ctx.get_state("second") { "two" } else { "one" })
2160 .timeline(motion_timeline("intro", motion_track(".",
2161 motion_transition("opacity", 0.0, 1.0,
2162 #{ duration_ms: 1000, easing: "linear" })), #{}))
2163 }
2164 "#;
2165 let schema = ComponentStateSchema::new(BTreeMap::from([(
2166 "second".to_owned(),
2167 StateField::new(ValueSchema::Bool, UiValue::Bool(false)),
2168 )]))
2169 .unwrap();
2170 let mut engine = RuntimeEngine::new();
2171 let compiled = engine.compile(source).unwrap();
2172 let now = Instant::now();
2173 let manual = crate::ManualRuntimeClock::new(now);
2174 let mut state = UiRuntimeState::new();
2175 state.clock = manual.clock();
2176 let runtime = Rc::new(RefCell::new(state));
2177 let root = ComponentInstancePath::root("View", "remount");
2178 let mut lifecycle = ScriptLifecycle::new(
2179 compiled,
2180 Rc::clone(&runtime),
2181 root.clone(),
2182 Some("window".to_owned()),
2183 BTreeMap::new(),
2184 &schema,
2185 )
2186 .unwrap()
2187 .with_view_id("remount");
2188 lifecycle.start(&mut engine).unwrap();
2189 let old_node = lifecycle.retained().root_id().unwrap();
2190 let old_handle = lifecycle
2191 .context(ExecutionPhase::Event)
2192 .motion_handle("intro")
2193 .unwrap();
2194 manual.advance(Duration::from_millis(500));
2195 runtime
2196 .borrow_mut()
2197 .set_component_state_from_host(&root, "second", UiValue::Bool(true))
2198 .unwrap();
2199 lifecycle.render_dirty(&mut engine).unwrap();
2200 let new_node = lifecycle.retained().root_id().unwrap();
2201 let new_handle = lifecycle
2202 .context(ExecutionPhase::Event)
2203 .motion_handle("intro")
2204 .unwrap();
2205 assert_ne!(old_node, new_node);
2206 assert_ne!(old_handle, new_handle);
2207 assert_eq!(runtime.borrow().motions.timeline_state(&old_handle), None);
2208 assert!(
2209 runtime
2210 .borrow()
2211 .motions
2212 .snapshot(manual.clock().now())
2213 .values()
2214 .next()
2215 .copied()
2216 .unwrap()
2217 .abs()
2218 < f64::EPSILON
2219 );
2220 }
2221
2222 #[test]
2223 fn geometry_motion_reserves_capacity_from_timeline_control() {
2224 let mut engine = RuntimeEngine::new();
2225 let compiled = engine
2226 .compile(
2227 r#"
2228 fn view(ctx) {
2229 text("card").with_key("card")
2230 .layout_motion(100, "linear")
2231 .timeline(motion_timeline("intro", motion_track(".",
2232 motion_transition("opacity", 0.0, 1.0,
2233 #{ duration_ms: 100 })), #{ autoplay: false }))
2234 }
2235 "#,
2236 )
2237 .unwrap();
2238 let mut state = UiRuntimeState::new();
2239 state.budgets.active_motions = 1;
2240 let runtime = Rc::new(RefCell::new(state));
2241 let mut lifecycle = ScriptLifecycle::new(
2242 compiled,
2243 Rc::clone(&runtime),
2244 ComponentInstancePath::root("View", "budget"),
2245 None,
2246 BTreeMap::new(),
2247 &ComponentStateSchema::default(),
2248 )
2249 .unwrap();
2250 lifecycle.start(&mut engine).unwrap();
2251 let context = lifecycle.context(ExecutionPhase::Event);
2252 let handle = context.motion_handle("intro").unwrap();
2253 assert!(matches!(
2254 context.play_motion(&handle),
2255 Err(crate::UiContextError::Motion(
2256 crate::MotionError::ActiveBudget {
2257 actual: 2,
2258 limit: 1,
2259 }
2260 ))
2261 ));
2262 }
2263
2264 #[test]
2265 fn motion_budget_admits_active_timeline_to_idle_plus_direct_replacement() {
2266 let source = r#"
2267 fn view(ctx) {
2268 let switching = ctx.get_state("go");
2269 let node = text("card").with_key("card").timeline(motion_timeline(
2270 "intro", motion_track(".", motion_transition("opacity", 0.0, 1.0,
2271 #{ duration_ms: 1000 })), #{ autoplay: !switching }));
2272 if switching {
2273 node.motion(motion_transition("width", 0.0, 100.0,
2274 #{ duration_ms: 1000 }))
2275 } else {
2276 node
2277 }
2278 }
2279 "#;
2280 let schema = ComponentStateSchema::new(BTreeMap::from([(
2281 "go".to_owned(),
2282 StateField::new(ValueSchema::Bool, UiValue::Bool(false)),
2283 )]))
2284 .unwrap();
2285 let mut engine = RuntimeEngine::new();
2286 let compiled = engine.compile(source).unwrap();
2287 let mut state = UiRuntimeState::new();
2288 state.budgets.active_motions = 1;
2289 let runtime = Rc::new(RefCell::new(state));
2290 let root = ComponentInstancePath::root("View", "budget-replacement");
2291 let mut lifecycle = ScriptLifecycle::new(
2292 compiled,
2293 Rc::clone(&runtime),
2294 root.clone(),
2295 None,
2296 BTreeMap::new(),
2297 &schema,
2298 )
2299 .unwrap();
2300 lifecycle.start(&mut engine).unwrap();
2301 runtime
2302 .borrow_mut()
2303 .set_component_state_from_host(&root, "go", UiValue::Bool(true))
2304 .unwrap();
2305 assert!(lifecycle.render_dirty(&mut engine).unwrap());
2306 assert_eq!(runtime.borrow().motions.resource_usage().active, 1);
2307 let handle = lifecycle
2308 .context(ExecutionPhase::Event)
2309 .motion_handle("intro")
2310 .unwrap();
2311 assert_eq!(
2312 runtime.borrow().motions.timeline_state(&handle),
2313 Some(crate::MotionPlaybackState::Idle)
2314 );
2315 }
2316
2317 #[test]
2318 fn virtual_item_motion_uses_the_stable_data_key_path() {
2319 let mut engine = RuntimeEngine::new();
2320 let compiled = engine
2321 .compile(
2322 r#"
2323 fn render_row(ctx, props) {
2324 text("A").with_key(props.key)
2325 .motion(motion_transition("opacity", 0.0, 1.0,
2326 #{ duration_ms: 1000 }))
2327 }
2328 fn view(ctx) {
2329 virtual_collection(#{
2330 key: "list",
2331 data: [#{ key: "alpha" }],
2332 height: 100,
2333 estimated_height: 20
2334 }, Fn("render_row"))
2335 }
2336 "#,
2337 )
2338 .unwrap();
2339 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2340 let mut lifecycle = ScriptLifecycle::new(
2341 compiled,
2342 Rc::clone(&runtime),
2343 ComponentInstancePath::root("View", "virtual"),
2344 None,
2345 BTreeMap::new(),
2346 &ComponentStateSchema::default(),
2347 )
2348 .unwrap();
2349 lifecycle.start(&mut engine).unwrap();
2350
2351 let values = runtime.borrow().motions.snapshot(Instant::now());
2352 let item = lifecycle
2353 .retained()
2354 .nodes()
2355 .find(|node| node.key() == Some("alpha"))
2356 .unwrap()
2357 .id();
2358 assert!(values.contains_key(&crate::MotionKey::for_node(
2359 &crate::motion::retained_node_path("root", item),
2360 crate::MotionProperty::Opacity,
2361 )));
2362 assert_eq!(values.len(), 1);
2363 }
2364
2365 #[test]
2366 fn exit_ghost_uses_the_views_committed_geometry_domain() {
2367 let mut engine = RuntimeEngine::new();
2368 let compiled = engine
2369 .compile(
2370 r#"
2371 fn view(ctx) {
2372 let children = [text(ctx.get_state("n").to_string()).with_key("counter")];
2373 if ctx.get_state("visible") {
2374 children.push(text("gone").with_key("panel")
2375 .exit_motion(motion_transition("opacity", 1.0, 0.0,
2376 #{ duration_ms: 1000, easing: "linear" })))
2377 }
2378 column(children)
2379 }
2380 "#,
2381 )
2382 .unwrap();
2383 let manual = crate::ManualRuntimeClock::new(Instant::now());
2384 let mut state = UiRuntimeState::new();
2385 state.clock = manual.clock();
2386 let runtime = Rc::new(RefCell::new(state));
2387 let root = ComponentInstancePath::root("View", "v");
2388 let schema = ComponentStateSchema::new(BTreeMap::from([
2389 (
2390 "visible".to_owned(),
2391 StateField::new(ValueSchema::Bool, UiValue::Bool(true)),
2392 ),
2393 (
2394 "n".to_owned(),
2395 StateField::new(ValueSchema::integer(), UiValue::Integer(0)),
2396 ),
2397 ]))
2398 .unwrap();
2399 let mut lifecycle = ScriptLifecycle::new(
2400 compiled,
2401 Rc::clone(&runtime),
2402 root.clone(),
2403 Some("w".to_owned()),
2404 BTreeMap::new(),
2405 &schema,
2406 )
2407 .unwrap()
2408 .with_view_id("v");
2409 lifecycle.start(&mut engine).unwrap();
2410 let panel = lifecycle
2411 .retained()
2412 .nodes()
2413 .find(|node| node.key() == Some("panel"))
2414 .unwrap()
2415 .id();
2416 let bounds = crate::GeometryBounds::new(0.0, 0.0, 100.0, 20.0).unwrap();
2417 runtime.borrow_mut().presentation_geometry("v").update(
2418 panel,
2419 crate::ElementGeometry {
2420 layout: bounds,
2421 visual: bounds,
2422 clip: None,
2423 },
2424 );
2425 runtime
2426 .borrow_mut()
2427 .set_component_state_from_host(&root, "visible", UiValue::Bool(false))
2428 .unwrap();
2429 lifecycle.render_dirty(&mut engine).unwrap();
2430
2431 let ghost_path = {
2432 let runtime = runtime.borrow();
2433 assert_eq!(runtime.motion_ghosts.len(), 1);
2434 assert_eq!(runtime.motion_ghosts[0].domain, "window:w/view:v/root");
2435 assert_eq!(runtime.motion_ghosts[0].bounds, bounds);
2436 assert!(
2437 runtime.motion_ghosts[0]
2438 .path
2439 .starts_with("window:w/view:v/root/ghost:")
2440 );
2441 assert!(runtime.motions.resource_usage().active > 0);
2442 runtime.motion_ghosts[0].path.clone()
2443 };
2444 manual.advance(Duration::from_millis(100));
2445 runtime
2446 .borrow_mut()
2447 .set_component_state_from_host(&root, "n", UiValue::Integer(1))
2448 .unwrap();
2449 lifecycle.render_dirty(&mut engine).unwrap();
2450 assert_eq!(runtime.borrow().motions.resource_usage().active, 1);
2451 assert!(
2452 (runtime.borrow().motions.snapshot(manual.clock().now())
2453 [&crate::MotionKey::for_node(&ghost_path, crate::MotionProperty::Opacity,)]
2454 - 0.9)
2455 .abs()
2456 < 0.01
2457 );
2458 let now = manual.clock().now();
2459 let frame = runtime
2460 .borrow_mut()
2461 .motions
2462 .tick_scope(now + Duration::from_millis(900), "window:w/view:v/root");
2463 assert!(frame.completed.contains(&crate::MotionKey::for_node(
2464 &ghost_path,
2465 crate::MotionProperty::Opacity,
2466 )));
2467 }
2468
2469 #[test]
2470 fn suspend_resume_preserves_state_and_freezes_motion_time() {
2471 let mut engine = RuntimeEngine::new();
2472 let compiled = engine
2473 .compile(
2474 r#"
2475 fn suspend(ctx) { ctx.set_state("phase", "suspended"); }
2476 fn resume(ctx, elapsed_ms) {
2477 ctx.set_state("phase", "resumed");
2478 ctx.set_state("elapsed", elapsed_ms);
2479 }
2480 fn view(ctx) {
2481 text(ctx.get_state("phase")).with_key("probe")
2482 .motion(motion_transition("width", 0.0, 100.0, #{ duration_ms: 100, easing: "linear" }))
2483 }
2484 "#,
2485 )
2486 .unwrap();
2487 let start = Instant::now();
2488 let manual = crate::ManualRuntimeClock::new(start);
2489 let mut runtime_state = UiRuntimeState::new();
2490 runtime_state.clock = manual.clock();
2491 let runtime = Rc::new(RefCell::new(runtime_state));
2492 let path = ComponentInstancePath::root("App", "root");
2493 let schema = ComponentStateSchema::new(BTreeMap::from([
2494 (
2495 "phase".to_owned(),
2496 StateField::new(ValueSchema::string(), UiValue::String("active".to_owned())),
2497 ),
2498 (
2499 "elapsed".to_owned(),
2500 StateField::new(ValueSchema::integer(), UiValue::Integer(0)),
2501 ),
2502 ]))
2503 .unwrap();
2504 let mut lifecycle = ScriptLifecycle::new(
2505 compiled,
2506 Rc::clone(&runtime),
2507 path.clone(),
2508 Some("main".to_owned()),
2509 BTreeMap::new(),
2510 &schema,
2511 )
2512 .unwrap();
2513 lifecycle.start(&mut engine).unwrap();
2514 manual.advance(Duration::from_millis(50));
2515 {
2516 let mut runtime = runtime.borrow_mut();
2517 let now = runtime.clock.now();
2518 runtime.motion_values = runtime.motions.tick(now).values;
2519 }
2520 let before = *runtime.borrow().motion_values.values().next().unwrap();
2521 assert!(lifecycle.suspend(&mut engine).unwrap());
2522 assert_eq!(lifecycle.state(), LifecycleState::Suspended);
2523 assert_eq!(
2524 runtime.borrow().component_state.get(&path, "phase"),
2525 Some(&UiValue::String("suspended".to_owned()))
2526 );
2527 assert!(!lifecycle.suspend(&mut engine).unwrap());
2528
2529 manual.advance(Duration::from_secs(1));
2530 {
2531 let mut runtime = runtime.borrow_mut();
2532 let now = runtime.clock.now();
2533 let frame = runtime.motions.tick(now);
2534 assert!(
2535 !frame.needs_frame,
2536 "a suspended scope must not keep another window's frame loop alive"
2537 );
2538 runtime.motion_values = frame.values;
2539 }
2540 assert!(
2541 (before - *runtime.borrow().motion_values.values().next().unwrap()).abs() < 0.001,
2542 "a shared runtime tick must not advance a suspended scope"
2543 );
2544 assert!(lifecycle.resume(&mut engine).unwrap());
2545 assert_eq!(lifecycle.state(), LifecycleState::Running);
2546 assert_eq!(
2547 runtime.borrow().component_state.get(&path, "elapsed"),
2548 Some(&UiValue::Integer(1_000))
2549 );
2550 let after = *runtime.borrow().motion_values.values().next().unwrap();
2551 assert!(
2552 (before - after).abs() < 0.001,
2553 "animation advanced while suspended"
2554 );
2555 assert!(!lifecycle.resume(&mut engine).unwrap());
2556 }
2557
2558 #[test]
2559 fn suspended_reload_migrates_atomically_and_keeps_last_good_on_failure() {
2560 let mut engine = RuntimeEngine::new();
2561 let active = engine
2562 .compile(
2563 r#"
2564 fn suspend(ctx) { ctx.set_state("phase", "parked"); }
2565 fn view(ctx) { text(ctx.get_state("phase")) }
2566 "#,
2567 )
2568 .unwrap();
2569 let active_generation = active.generation();
2570 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2571 let path = ComponentInstancePath::root("App", "root");
2572 let mut lifecycle = ScriptLifecycle::new(
2573 active,
2574 Rc::clone(&runtime),
2575 path.clone(),
2576 Some("main".to_owned()),
2577 BTreeMap::new(),
2578 &state_schema(),
2579 )
2580 .unwrap();
2581 lifecycle.start(&mut engine).unwrap();
2582 lifecycle.suspend(&mut engine).unwrap();
2583
2584 let rejected = engine
2585 .compile(
2586 r#"
2587 fn init(ctx) { ctx.set_state("phase", "candidate-init"); }
2588 fn resume(ctx, elapsed_ms) { throw "candidate resume failed"; }
2589 fn view(ctx) { text(ctx.get_state("phase")) }
2590 "#,
2591 )
2592 .unwrap();
2593 assert!(
2594 lifecycle
2595 .resume_reload(&mut engine, rejected, &state_schema())
2596 .is_err()
2597 );
2598 assert_eq!(lifecycle.state(), LifecycleState::Suspended);
2599 assert_eq!(lifecycle.generation(), active_generation);
2600 assert_eq!(
2601 runtime.borrow().component_state.get(&path, "phase"),
2602 Some(&UiValue::String("parked".to_owned()))
2603 );
2604
2605 let accepted = engine
2606 .compile(
2607 r#"
2608 fn init(ctx) { ctx.set_state("phase", "candidate-init"); }
2609 fn resume(ctx, elapsed_ms) { ctx.set_state("phase", "candidate-resumed"); }
2610 fn view(ctx) { text(ctx.get_state("phase")) }
2611 "#,
2612 )
2613 .unwrap();
2614 let accepted_generation = accepted.generation();
2615 lifecycle
2616 .resume_reload(&mut engine, accepted, &state_schema())
2617 .unwrap();
2618 assert_eq!(lifecycle.state(), LifecycleState::Running);
2619 assert_eq!(lifecycle.generation(), accepted_generation);
2620 assert_eq!(
2621 runtime.borrow().component_state.get(&path, "phase"),
2622 Some(&UiValue::String("candidate-resumed".to_owned()))
2623 );
2624 }
2625
2626 #[test]
2627 #[allow(clippy::too_many_lines)]
2628 fn virtual_realization_commits_the_complete_target_window_and_prunes_old_items() {
2629 let mut engine = RuntimeEngine::new();
2630 let compiled = engine
2631 .compile(
2632 r#"
2633 fn render_item(ctx, payload) {
2634 text(payload.item.label).with_key(payload.key)
2635 }
2636 fn view(ctx) {
2637 let data = [];
2638 for index in 0..32 {
2639 data.push(#{ key: `row-${index}`, label: `Row ${index}` });
2640 }
2641 virtual_collection(#{
2642 key: "rows", label: "Rows", data: data,
2643 estimated_height: 24, height: 96,
2644 overdraw_pixels: 24, alignment: "top", follow_tail: false
2645 }, Fn("render_item"))
2646 }
2647 "#,
2648 )
2649 .unwrap();
2650 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2651 let path = ComponentInstancePath::root("App", "root");
2652 let mut lifecycle = ScriptLifecycle::new(
2653 compiled,
2654 Rc::clone(&runtime),
2655 path.clone(),
2656 Some("main".to_owned()),
2657 BTreeMap::new(),
2658 &ComponentStateSchema::default(),
2659 )
2660 .unwrap();
2661 lifecycle.start(&mut engine).unwrap();
2662 let id = engine
2663 .virtual_collection_ids_in_scope(&path)
2664 .into_iter()
2665 .next()
2666 .unwrap();
2667 let initial_indices = lifecycle
2668 .root()
2669 .unwrap()
2670 .virtual_collection_items(&id)
2671 .unwrap()
2672 .keys()
2673 .copied()
2674 .collect::<Vec<_>>();
2675 let retained = *initial_indices.last().unwrap();
2676 let first_new = retained + 1;
2677 let second_new = first_new + 1;
2678 let _ = engine.take_timings();
2679
2680 runtime
2681 .borrow()
2682 .virtual_requests
2683 .request(id.clone(), [retained, first_new]);
2684 assert!(lifecycle.realize_virtual_requests(&mut engine).unwrap());
2685 assert_eq!(
2686 lifecycle
2687 .root()
2688 .unwrap()
2689 .virtual_collection_items(&id)
2690 .unwrap()
2691 .keys()
2692 .copied()
2693 .collect::<Vec<_>>(),
2694 vec![retained, first_new]
2695 );
2696 let timings = engine.take_timings();
2697 assert_eq!(
2698 timings
2699 .iter()
2700 .filter(|timing| matches!(
2701 timing.operation,
2702 ExecutionOperation::VirtualCollection(_)
2703 ))
2704 .count(),
2705 1
2706 );
2707 assert!(timings.iter().all(|timing| timing.operations > 0));
2708
2709 runtime
2710 .borrow()
2711 .virtual_requests
2712 .request(id.clone(), [retained, first_new]);
2713 assert!(!lifecycle.realize_virtual_requests(&mut engine).unwrap());
2714 assert!(engine.take_timings().is_empty());
2715
2716 runtime
2717 .borrow()
2718 .virtual_requests
2719 .request(id.clone(), [first_new, second_new]);
2720 assert!(lifecycle.realize_virtual_requests(&mut engine).unwrap());
2721 assert_eq!(
2722 lifecycle
2723 .root()
2724 .unwrap()
2725 .virtual_collection_items(&id)
2726 .unwrap()
2727 .keys()
2728 .copied()
2729 .collect::<Vec<_>>(),
2730 vec![first_new, second_new]
2731 );
2732 assert_eq!(
2733 engine
2734 .take_timings()
2735 .iter()
2736 .filter(|timing| matches!(
2737 timing.operation,
2738 ExecutionOperation::VirtualCollection(_)
2739 ))
2740 .count(),
2741 1
2742 );
2743 }
2744
2745 #[test]
2746 #[allow(clippy::too_many_lines)]
2747 fn virtual_target_commit_retains_and_releases_the_complete_component_manifest() {
2748 fn counter_callback(node: &UiNode) -> Option<ScriptCallback> {
2749 if let Some(handler) = node.handler("click") {
2750 return handler.as_script().cloned();
2751 }
2752 match node.kind() {
2753 crate::UiNodeKind::Box { children } | crate::UiNodeKind::Fragment { children } => {
2754 children.iter().find_map(counter_callback)
2755 }
2756 _ => None,
2757 }
2758 }
2759 for (wrapper, row) in [
2760 (
2761 "child",
2762 r#"render_component("test/virtual_counter",#{key:payload.key})"#,
2763 ),
2764 (
2765 "child",
2766 r#"render_component("test/wrapper",#{key:payload.key,depth:0})"#,
2767 ),
2768 (
2769 "child",
2770 r#"render_component("test/wrapper",#{key:payload.key,depth:2})"#,
2771 ),
2772 (
2773 "column([child])",
2774 r#"render_component("test/wrapper",#{key:payload.key,depth:0})"#,
2775 ),
2776 ] {
2777 let mut engine = RuntimeEngine::new();
2778 let source = r#"
2779 define_component(#{
2780 metadata: #{id:"test/virtual_counter", "export":"Counter", version:"0.1.8",
2781 runtime_api:#{min_inclusive:2,max_exclusive:3},dependencies:[],capabilities:#{}},
2782 schema: #{props:#{key:#{schema:#{type:"string"},required:true,sensitive:false}},
2783 state:#{fields:#{count:#{schema:#{type:"integer"},"default":#{type:"integer",value:7}}}},
2784 events:#{},slots:#{},parts:[],effects:["keep"]},render:Fn("render_counter")
2785 });
2786 fn read_count(ctx,payload) {ctx.get_state("count")}
2787 fn start(ctx,deps) {()}
2788 fn cleanup(ctx,deps) {()}
2789 fn tick(ctx,value) {()}
2790 fn render_counter(ctx,props) {
2791 effect("keep",(),Fn("start"),Fn("cleanup"));
2792 timeout("tick",1000,false,Fn("tick"),());
2793 let opacity=signal("opacity",1.0);
2794 text(`${ctx.get_state("count")}`).with_key(props.key).bind_signal("opacity",opacity)
2795 .with_ref(element_ref("counter")).on_click(Fn("read_count"))
2796 }
2797 define_component(#{
2798 metadata:#{id:"test/wrapper","export":"Wrapper",version:"0.1.8",
2799 runtime_api:#{min_inclusive:2,max_exclusive:3},dependencies:[],capabilities:#{}},
2800 schema:#{props:#{key:#{schema:#{type:"string"},required:true,sensitive:false},
2801 depth:#{schema:#{type:"integer"},required:true,sensitive:false}},
2802 state:#{fields:#{}},events:#{},slots:#{},parts:[]},render:Fn("render_wrapper")
2803 });
2804 fn render_wrapper(ctx,props) {
2805 let child=if props.depth==0 {
2806 render_component("test/virtual_counter",#{key:"inner"})
2807 } else {
2808 render_component("test/wrapper",#{key:"inner",depth:props.depth-1})
2809 };
2810 WRAPPER_BODY
2811 }
2812 fn row(ctx,payload) {
2813 if payload.index==19 {throw "candidate row failed";}
2814 ROW_BODY
2815 }
2816 fn view(ctx) {
2817 let data=[];for index in 0..20 {data.push(#{key:`row-${index}`});}
2818 virtual_collection(#{key:"rows",label:"Rows",data:data,height:24,
2819 estimated_height:24,overdraw_pixels:0},Fn("row"))
2820 }
2821 "#.replace("WRAPPER_BODY",wrapper).replace("ROW_BODY",row);
2822 let compiled = engine.compile(&source).unwrap();
2823 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2824 let path = ComponentInstancePath::root("App", "manifest");
2825 let mut lifecycle = ScriptLifecycle::new(
2826 compiled,
2827 Rc::clone(&runtime),
2828 path.clone(),
2829 None,
2830 BTreeMap::new(),
2831 &ComponentStateSchema::default(),
2832 )
2833 .unwrap();
2834 lifecycle.start(&mut engine).unwrap();
2835 let id = engine
2836 .virtual_collection_ids_in_scope(&path)
2837 .into_iter()
2838 .next()
2839 .unwrap();
2840 let callback = counter_callback(
2841 &lifecycle
2842 .root()
2843 .unwrap()
2844 .virtual_collection_items(&id)
2845 .unwrap()[&0],
2846 )
2847 .unwrap();
2848 let child = callback.component().unwrap().clone();
2849 let signal = runtime.borrow().signals.resolve(&child, "opacity").unwrap();
2850 let initial_effects = runtime
2851 .borrow()
2852 .effects
2853 .iter_active()
2854 .filter(|(id, _, _)| id.component() == &child)
2855 .map(|(id, _, activation)| (id.clone(), activation))
2856 .collect::<Vec<_>>();
2857 runtime
2858 .borrow()
2859 .virtual_requests
2860 .request(id.clone(), [0, 10]);
2861 lifecycle.realize_virtual_requests(&mut engine).unwrap();
2862 assert_eq!(
2863 lifecycle
2864 .invoke_callback(&engine, &callback, UiValue::Null)
2865 .unwrap()
2866 .as_int()
2867 .unwrap(),
2868 7
2869 );
2870 assert_eq!(
2871 runtime
2872 .borrow()
2873 .effects
2874 .iter_active()
2875 .filter(|(id, _, _)| id.component() == &child)
2876 .map(|(id, _, activation)| (id.clone(), activation))
2877 .collect::<Vec<_>>(),
2878 initial_effects
2879 );
2880 assert!(runtime.borrow().signals.read(&signal).is_ok());
2881 assert_eq!(engine.component_timers_in_scope(&child).len(), 1);
2882 assert_eq!(engine.component_element_refs_in_scope(&child).len(), 1);
2883 let event = UiContext::new(
2884 Rc::clone(&runtime),
2885 child.clone(),
2886 None,
2887 ExecutionPhase::Event,
2888 BTreeMap::new(),
2889 );
2890 event
2891 .set_state("count", UiValue::Integer(8).into_dynamic())
2892 .unwrap();
2893 assert!(lifecycle.render_dirty(&mut engine).unwrap());
2894 assert_eq!(
2895 lifecycle
2896 .invoke_callback(&engine, &callback, UiValue::Null)
2897 .unwrap()
2898 .as_int()
2899 .unwrap(),
2900 8,
2901 "transparent descendants remain executable after scrolling"
2902 );
2903 runtime
2904 .borrow()
2905 .virtual_requests
2906 .request(id.clone(), [0, 10, 19]);
2907 assert!(lifecycle.realize_virtual_requests(&mut engine).is_err());
2908 assert_eq!(
2909 lifecycle
2910 .root()
2911 .unwrap()
2912 .virtual_collection_items(&id)
2913 .unwrap()
2914 .keys()
2915 .copied()
2916 .collect::<Vec<_>>(),
2917 vec![0, 10]
2918 );
2919 assert!(runtime.borrow().signals.read(&signal).is_ok());
2920 runtime.borrow().virtual_requests.request_target(id, [10]);
2921 lifecycle.realize_virtual_requests(&mut engine).unwrap();
2922 assert!(
2923 runtime
2924 .borrow()
2925 .component_state
2926 .get(&child, "count")
2927 .is_none()
2928 );
2929 assert!(
2930 lifecycle
2931 .invoke_callback(&engine, &callback, UiValue::Null)
2932 .is_err()
2933 );
2934 assert!(runtime.borrow().signals.read(&signal).is_err());
2935 assert!(engine.component_timers_in_scope(&child).is_empty());
2936 assert!(engine.component_element_refs_in_scope(&child).is_empty());
2937 assert!(
2938 !runtime
2939 .borrow()
2940 .effects
2941 .iter()
2942 .any(|(id, _)| id.component() == &child)
2943 );
2944 }
2945 }
2946
2947 #[test]
2948 #[allow(clippy::too_many_lines)]
2949 fn nested_virtual_targets_preserve_callbacks_snapshots_and_prune_atomically() {
2950 let mut engine = RuntimeEngine::new();
2951 let compiled = engine.compile(r#"
2952 define_component(#{
2953 metadata:#{id:"test/panel","export":"Panel",version:"0.1.8",
2954 runtime_api:#{min_inclusive:2,max_exclusive:3},dependencies:[],capabilities:#{}},
2955 schema:#{props:#{key:#{schema:#{type:"string"},required:true,sensitive:false}},
2956 state:#{fields:#{count:#{schema:#{type:"integer"},"default":#{type:"integer",value:7}}}},
2957 events:#{},slots:#{},parts:[]},render:Fn("render_panel")
2958 });
2959 fn data() {let rows=[];for i in 0..20 {rows.push(#{key:`row-${i}`});}rows}
2960 fn read_count(ctx,payload) {ctx.get_state("count")}
2961 fn inner_row(ctx,payload) {
2962 if payload.index==19 {throw "candidate rejected";}
2963 text(payload.key).on_click(Fn("read_count"))
2964 }
2965 fn outer_row(ctx,payload) {
2966 virtual_collection(#{key:`inner-${payload.key}`,label:"Inner",data:data(),height:24,
2967 estimated_height:24,overdraw_pixels:0},Fn("inner_row"))
2968 }
2969 fn render_panel(ctx,props) {
2970 virtual_collection(#{key:"outer",label:"Outer",data:data(),height:24,
2971 estimated_height:24,overdraw_pixels:0},Fn("outer_row"))
2972 }
2973 fn view(ctx) {column([text(`${ctx.get_state("other")}`),render_component("test/panel",#{key:"panel"})])}
2974 "#).unwrap();
2975 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2976 let path = ComponentInstancePath::root("App", "nested");
2977 let schema = ComponentStateSchema::new(BTreeMap::from([(
2978 "other".into(),
2979 crate::StateField::new(crate::ValueSchema::integer(), UiValue::Integer(0)),
2980 )]))
2981 .unwrap();
2982 let mut lifecycle = ScriptLifecycle::new(
2983 compiled,
2984 Rc::clone(&runtime),
2985 path.clone(),
2986 None,
2987 BTreeMap::new(),
2988 &schema,
2989 )
2990 .unwrap();
2991 lifecycle.start(&mut engine).unwrap();
2992 let panel = path.child("Panel", "panel");
2993 let ids = engine.virtual_collection_ids_in_scope(&path);
2994 let outer = ids.iter().find(|id| id.key == "outer").unwrap().clone();
2995 let inner = ids
2996 .iter()
2997 .find(|id| id.key == "inner-row-0")
2998 .unwrap()
2999 .clone();
3000 runtime
3001 .borrow()
3002 .virtual_requests
3003 .request_target(inner.clone(), [10]);
3004 lifecycle.realize_virtual_requests(&mut engine).unwrap();
3005 let callback = lifecycle
3006 .root()
3007 .unwrap()
3008 .virtual_collection_items(&inner)
3009 .unwrap()[&10]
3010 .handler("click")
3011 .unwrap()
3012 .as_script()
3013 .unwrap()
3014 .clone();
3015 assert_eq!(callback.component(), Some(&panel));
3016 assert_eq!(
3017 lifecycle
3018 .invoke_callback(&engine, &callback, UiValue::Null)
3019 .unwrap()
3020 .as_int()
3021 .unwrap(),
3022 7
3023 );
3024
3025 let context = UiContext::new(
3028 Rc::clone(&runtime),
3029 path,
3030 None,
3031 ExecutionPhase::Event,
3032 BTreeMap::new(),
3033 );
3034 context
3035 .set_state("other", UiValue::Integer(1).into_dynamic())
3036 .unwrap();
3037 lifecycle.render_dirty(&mut engine).unwrap();
3038 assert_eq!(
3039 lifecycle
3040 .root()
3041 .unwrap()
3042 .virtual_collection_items(&inner)
3043 .unwrap()
3044 .keys()
3045 .copied()
3046 .collect::<Vec<_>>(),
3047 vec![10]
3048 );
3049
3050 runtime
3052 .borrow()
3053 .virtual_requests
3054 .request_target(outer.clone(), [0, 10]);
3055 lifecycle.realize_virtual_requests(&mut engine).unwrap();
3056 assert!(
3057 engine
3058 .virtual_collection_ids_in_scope(&panel)
3059 .contains(&inner)
3060 );
3061 runtime
3062 .borrow()
3063 .virtual_requests
3064 .request_target(inner.clone(), [10, 19]);
3065 assert!(lifecycle.realize_virtual_requests(&mut engine).is_err());
3066 assert_eq!(
3067 lifecycle
3068 .root()
3069 .unwrap()
3070 .virtual_collection_items(&inner)
3071 .unwrap()
3072 .keys()
3073 .copied()
3074 .collect::<Vec<_>>(),
3075 vec![10]
3076 );
3077
3078 runtime
3081 .borrow()
3082 .virtual_requests
3083 .request_target(outer, [10]);
3084 runtime
3085 .borrow()
3086 .virtual_requests
3087 .request_target(inner.clone(), [11]);
3088 lifecycle.realize_virtual_requests(&mut engine).unwrap();
3089 assert!(
3090 lifecycle
3091 .root()
3092 .unwrap()
3093 .virtual_collection_spec(&inner)
3094 .is_none()
3095 );
3096 assert!(
3097 !engine
3098 .virtual_collection_ids_in_scope(&panel)
3099 .contains(&inner)
3100 );
3101 }
3102
3103 #[test]
3104 fn controlled_rerender_synchronously_rebuilds_the_retained_virtual_window() {
3105 let mut engine = RuntimeEngine::new();
3106 let compiled = engine
3107 .compile(
3108 r#"
3109 fn render_item(ctx, payload) {
3110 text(`${payload.item.label}:${payload.item.active}`).with_key(payload.key)
3111 }
3112 fn view(ctx) {
3113 let active = ctx.get_state("active");
3114 let data = [];
3115 for index in 0..32 {
3116 data.push(#{
3117 key: `row-${index}`,
3118 label: `Row ${index}`,
3119 active: index == active,
3120 });
3121 }
3122 virtual_collection(#{
3123 key: "rows", label: "Rows", data: data,
3124 estimated_height: 24, height: 96,
3125 overdraw_pixels: 24, alignment: "top", follow_tail: false,
3126 reveal_key: `row-${active}`,
3127 }, Fn("render_item"))
3128 }
3129 "#,
3130 )
3131 .unwrap();
3132 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3133 let path = ComponentInstancePath::root("App", "root");
3134 let schema = ComponentStateSchema::new(BTreeMap::from([(
3135 "active".to_owned(),
3136 StateField::new(ValueSchema::integer(), UiValue::Integer(0)),
3137 )]))
3138 .unwrap();
3139 let mut lifecycle = ScriptLifecycle::new(
3140 compiled,
3141 Rc::clone(&runtime),
3142 path.clone(),
3143 Some("main".to_owned()),
3144 BTreeMap::new(),
3145 &schema,
3146 )
3147 .unwrap();
3148 lifecycle.start(&mut engine).unwrap();
3149 let id = engine
3150 .virtual_collection_ids_in_scope(&path)
3151 .into_iter()
3152 .next()
3153 .unwrap();
3154
3155 runtime
3156 .borrow()
3157 .virtual_requests
3158 .request(id.clone(), 14..20);
3159 assert!(lifecycle.realize_virtual_requests(&mut engine).unwrap());
3160 assert_eq!(
3161 lifecycle
3162 .root()
3163 .unwrap()
3164 .virtual_collection_items(&id)
3165 .unwrap()
3166 .keys()
3167 .copied()
3168 .collect::<Vec<_>>(),
3169 (14..20).collect::<Vec<_>>()
3170 );
3171
3172 runtime
3173 .borrow_mut()
3174 .set_component_state_from_host(&path, "active", UiValue::Integer(16))
3175 .unwrap();
3176 assert!(lifecycle.render_dirty(&mut engine).unwrap());
3177 let realized = lifecycle
3178 .root()
3179 .unwrap()
3180 .virtual_collection_items(&id)
3181 .unwrap();
3182 for index in 14..20 {
3183 assert!(
3184 realized.contains_key(&index),
3185 "retained row {index} is missing"
3186 );
3187 }
3188 assert!(matches!(
3189 realized[&16].kind(),
3190 crate::UiNodeKind::Text { text } if text == "Row 16:true"
3191 ));
3192 assert!(matches!(
3193 realized[&15].kind(),
3194 crate::UiNodeKind::Text { text } if text == "Row 15:false"
3195 ));
3196 }
3197
3198 #[test]
3199 fn failed_event_callback_rolls_back_ui_state() {
3200 let mut engine = RuntimeEngine::new();
3201 let compiled = engine
3202 .compile(
3203 r#"
3204 fn view(ctx) { text(ctx.get_state("phase")) }
3205 fn fail(ctx, payload) {
3206 ctx.set_state("phase", "partial");
3207 ctx.register_action("test.temporary", Fn("fail"));
3208 ctx.open_window("temporary", "Temporary", 400, 300, false);
3209 throw "event failed";
3210 }
3211 "#,
3212 )
3213 .unwrap();
3214 let callback = engine.callback(&compiled, "fail").unwrap();
3215 let mut runtime_state = UiRuntimeState::new();
3216 runtime_state.windows.register_open("main").unwrap();
3217 let runtime = Rc::new(RefCell::new(runtime_state));
3218 let path = ComponentInstancePath::root("App", "root");
3219 let mut lifecycle = ScriptLifecycle::new(
3220 compiled,
3221 Rc::clone(&runtime),
3222 path.clone(),
3223 Some("main".to_owned()),
3224 BTreeMap::new(),
3225 &state_schema(),
3226 )
3227 .unwrap();
3228 lifecycle.start(&mut engine).unwrap();
3229 assert!(
3230 lifecycle
3231 .invoke_callback_transactional(&engine, &callback, UiValue::Null)
3232 .is_err()
3233 );
3234 assert_eq!(
3235 runtime.borrow().component_state.get(&path, "phase"),
3236 Some(&UiValue::String("created".to_owned()))
3237 );
3238 let runtime = runtime.borrow();
3239 assert!(!runtime.windows.contains("temporary"));
3240 assert!(
3241 runtime
3242 .actions
3243 .dispatch(
3244 &crate::ActionId::parse("test.temporary").unwrap(),
3245 UiValue::Null,
3246 )
3247 .is_err()
3248 );
3249 }
3250
3251 #[test]
3252 fn missing_optional_lifecycle_functions_are_valid() {
3253 let mut engine = RuntimeEngine::new();
3254 let compiled = engine
3255 .compile("fn view(ctx) { text(\"only view\") }")
3256 .unwrap();
3257 let mut lifecycle = ScriptLifecycle::new(
3258 compiled,
3259 Rc::new(RefCell::new(UiRuntimeState::new())),
3260 ComponentInstancePath::root("App", "root"),
3261 None,
3262 BTreeMap::new(),
3263 &ComponentStateSchema::default(),
3264 )
3265 .unwrap();
3266 lifecycle.start(&mut engine).unwrap();
3267 lifecycle.dispose(&mut engine).unwrap();
3268 }
3269
3270 #[test]
3271 fn retained_candidate_over_host_budget_is_rejected_before_commit() {
3272 let mut engine = RuntimeEngine::new();
3273 let compiled = engine
3274 .compile("fn view(ctx) { box([text(\"child\")]) }")
3275 .unwrap();
3276 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3277 runtime.borrow_mut().budgets.retained_nodes = 1;
3278 let mut lifecycle = ScriptLifecycle::new(
3279 compiled,
3280 Rc::clone(&runtime),
3281 ComponentInstancePath::root("App", "root"),
3282 Some("main".to_owned()),
3283 BTreeMap::new(),
3284 &ComponentStateSchema::default(),
3285 )
3286 .unwrap();
3287 assert!(matches!(
3288 lifecycle.start(&mut engine),
3289 Err(LifecycleError::Budget(
3290 crate::RuntimeBudgetError::Exceeded {
3291 resource: "retained_nodes",
3292 actual: 2,
3293 limit: 1,
3294 }
3295 ))
3296 ));
3297 assert!(lifecycle.retained().is_empty());
3298 }
3299
3300 #[test]
3301 fn invalid_native_semantics_reject_the_candidate_before_commit() {
3302 let mut engine = RuntimeEngine::new();
3303 let compiled = engine
3304 .compile(
3305 r#"fn view(ctx) {
3306 text("Mystery").accessibility_role("mystery_widget")
3307 }"#,
3308 )
3309 .unwrap();
3310 let mut lifecycle = ScriptLifecycle::new(
3311 compiled,
3312 Rc::new(RefCell::new(UiRuntimeState::new())),
3313 ComponentInstancePath::root("App", "root"),
3314 None,
3315 BTreeMap::new(),
3316 &ComponentStateSchema::default(),
3317 )
3318 .unwrap();
3319 assert!(matches!(
3320 lifecycle.start(&mut engine),
3321 Err(LifecycleError::Accessibility(
3322 crate::AccessibilityError::UnsupportedRole(role)
3323 )) if role == "mystery_widget"
3324 ));
3325 assert!(lifecycle.retained().is_empty());
3326 assert!(lifecycle.semantics().nodes().next().is_none());
3327 }
3328
3329 #[test]
3330 fn layer_budget_rejects_candidate_before_commit() {
3331 let mut engine = RuntimeEngine::new();
3332 let compiled = engine
3333 .compile(
3334 r#"
3335 fn view(ctx) {
3336 box([
3337 layer(text("first"), #{ id: "first", placement: "top_right" }),
3338 layer(text("second"), #{ id: "second", placement: "bottom_right" })
3339 ])
3340 }
3341 "#,
3342 )
3343 .unwrap();
3344 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3345 runtime.borrow_mut().budgets.layers = 1;
3346 let mut lifecycle = ScriptLifecycle::new(
3347 compiled,
3348 Rc::clone(&runtime),
3349 ComponentInstancePath::root("App", "root"),
3350 Some("main".to_owned()),
3351 BTreeMap::new(),
3352 &ComponentStateSchema::default(),
3353 )
3354 .unwrap();
3355
3356 assert!(matches!(
3357 lifecycle.start(&mut engine),
3358 Err(LifecycleError::Budget(
3359 crate::RuntimeBudgetError::Exceeded {
3360 resource: "layers",
3361 actual: 2,
3362 limit: 1,
3363 }
3364 ))
3365 ));
3366 assert!(lifecycle.retained().is_empty());
3367 }
3368
3369 #[test]
3370 fn canvas_scene_budget_is_independent_from_command_budget() {
3371 let mut engine = RuntimeEngine::new();
3372 let compiled = engine
3373 .compile(
3374 r#"
3375 fn view(ctx) {
3376 box([
3377 canvas(canvas_scene([canvas_rect("a", 0.0, 0.0, 1.0, 1.0, rgb(0))])),
3378 canvas(canvas_scene([canvas_rect("b", 0.0, 0.0, 1.0, 1.0, rgb(0))]))
3379 ])
3380 }
3381 "#,
3382 )
3383 .unwrap();
3384 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3385 {
3386 let mut runtime = runtime.borrow_mut();
3387 runtime.budgets.canvas_scenes = 1;
3388 runtime.budgets.canvas_commands = 10;
3389 }
3390 let mut lifecycle = ScriptLifecycle::new(
3391 compiled,
3392 Rc::clone(&runtime),
3393 ComponentInstancePath::root("App", "root"),
3394 Some("main".to_owned()),
3395 BTreeMap::new(),
3396 &ComponentStateSchema::default(),
3397 )
3398 .unwrap();
3399
3400 assert!(matches!(
3401 lifecycle.start(&mut engine),
3402 Err(LifecycleError::Budget(
3403 crate::RuntimeBudgetError::Exceeded {
3404 resource: "canvas_scenes",
3405 actual: 2,
3406 limit: 1,
3407 }
3408 ))
3409 ));
3410 assert!(lifecycle.retained().is_empty());
3411 }
3412
3413 #[test]
3414 fn hot_reload_rolls_back_candidate_init_state_on_render_failure() {
3415 let mut engine = RuntimeEngine::new();
3416 let active = engine
3417 .compile(
3418 r#"
3419 fn init(ctx) { ctx.set_state("phase", "active"); }
3420 fn view(ctx) { text(ctx.get_state("phase")) }
3421 "#,
3422 )
3423 .unwrap();
3424 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3425 let path = ComponentInstancePath::root("App", "root");
3426 let mut lifecycle = ScriptLifecycle::new(
3427 active,
3428 Rc::clone(&runtime),
3429 path.clone(),
3430 None,
3431 BTreeMap::new(),
3432 &state_schema(),
3433 )
3434 .unwrap();
3435 lifecycle.start(&mut engine).unwrap();
3436 let active_generation = lifecycle.generation();
3437
3438 let rejected = engine
3439 .compile(
3440 r#"
3441 fn init(ctx) { ctx.set_state("phase", "candidate"); }
3442 fn view(ctx) { throw "reject"; }
3443 "#,
3444 )
3445 .unwrap();
3446 assert!(
3447 lifecycle
3448 .reload(&mut engine, rejected, &state_schema())
3449 .is_err()
3450 );
3451 assert_eq!(lifecycle.generation(), active_generation);
3452 assert_eq!(
3453 runtime.borrow().component_state.get(&path, "phase"),
3454 Some(&UiValue::String("active".to_owned()))
3455 );
3456 }
3457
3458 #[test]
3459 fn hot_reload_rolls_back_engine_generation_after_retained_validation_failure() {
3460 let mut engine = RuntimeEngine::new();
3461 let active = engine
3462 .compile(
3463 r#"
3464 fn init(ctx) { ctx.set_state("phase", "active"); }
3465 fn view(ctx) { text(ctx.get_state("phase")) }
3466 "#,
3467 )
3468 .unwrap();
3469 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3470 let path = ComponentInstancePath::root("App", "root");
3471 let mut lifecycle = ScriptLifecycle::new(
3472 active,
3473 Rc::clone(&runtime),
3474 path.clone(),
3475 None,
3476 BTreeMap::new(),
3477 &state_schema(),
3478 )
3479 .unwrap();
3480 lifecycle.start(&mut engine).unwrap();
3481 let active_generation = lifecycle.generation();
3482 let reconcile_trace_count = runtime
3483 .borrow()
3484 .traces
3485 .snapshot()
3486 .iter()
3487 .filter(|trace| trace.kind == crate::RuntimeTraceKind::Reconcile)
3488 .count();
3489
3490 let rejected = engine
3491 .compile(
3492 r#"
3493 fn init(ctx) { ctx.set_state("phase", "candidate"); }
3494 fn view(ctx) {
3495 row([
3496 text("first").with_key("duplicate"),
3497 text("second").with_key("duplicate")
3498 ])
3499 }
3500 "#,
3501 )
3502 .unwrap();
3503 assert!(
3504 lifecycle
3505 .reload(&mut engine, rejected, &state_schema())
3506 .is_err()
3507 );
3508 assert_eq!(lifecycle.generation(), active_generation);
3509 assert!(engine.is_current(active_generation));
3510 assert_eq!(
3511 runtime.borrow().component_state.get(&path, "phase"),
3512 Some(&UiValue::String("active".to_owned()))
3513 );
3514 assert!(matches!(
3515 lifecycle.root().unwrap().kind(),
3516 crate::UiNodeKind::Text { text } if text == "active"
3517 ));
3518 assert_eq!(
3519 runtime
3520 .borrow()
3521 .traces
3522 .snapshot()
3523 .iter()
3524 .filter(|trace| trace.kind == crate::RuntimeTraceKind::Reconcile)
3525 .count(),
3526 reconcile_trace_count
3527 );
3528 }
3529
3530 #[test]
3531 fn async_capability_completes_through_foreground_delivery() {
3532 struct Echo;
3533 impl AsyncCapabilityHandler for Echo {
3534 fn start(&mut self, _: &str, input: UiValue) -> Result<TaskWork, String> {
3535 Ok(TaskWork::new(move || Ok(input)))
3536 }
3537 }
3538
3539 let mut engine = RuntimeEngine::new();
3540 let compiled = engine
3541 .compile(
3542 r#"
3543 fn init(ctx) {
3544 ctx.start_task(
3545 "app.echo", "echo", "loaded",
3546 Fn("loaded"), Fn("failed")
3547 );
3548 }
3549 fn loaded(ctx, value) { ctx.set_state("phase", value); }
3550 fn failed(ctx, error) { ctx.set_state("phase", "failed"); }
3551 fn view(ctx) { text(ctx.get_state("phase")) }
3552 "#,
3553 )
3554 .unwrap();
3555 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3556 let capability = CapabilityId::parse("app.echo").unwrap();
3557 runtime
3558 .borrow_mut()
3559 .capabilities
3560 .register_async(
3561 CapabilityDescriptor {
3562 id: capability.clone(),
3563 version: Version::new(1, 0, 0),
3564 methods: BTreeMap::from([(
3565 "echo".to_owned(),
3566 CapabilityMethod {
3567 input: ValueSchema::string(),
3568 output: ValueSchema::string(),
3569 },
3570 )]),
3571 },
3572 Echo,
3573 )
3574 .unwrap();
3575 runtime
3576 .borrow_mut()
3577 .capabilities
3578 .activate(&BTreeMap::from([(capability, VersionReq::STAR)]))
3579 .unwrap();
3580 let path = ComponentInstancePath::root("App", "root");
3581 let mut lifecycle = ScriptLifecycle::new(
3582 compiled,
3583 Rc::clone(&runtime),
3584 path.clone(),
3585 None,
3586 BTreeMap::new(),
3587 &state_schema(),
3588 )
3589 .unwrap();
3590 lifecycle.start(&mut engine).unwrap();
3591
3592 let deadline = Instant::now() + Duration::from_secs(1);
3593 loop {
3594 let deliveries = runtime.borrow_mut().tasks.drain(lifecycle.generation());
3595 if !deliveries.is_empty() {
3596 for delivery in deliveries {
3597 let _ = lifecycle.invoke_async_delivery(&engine, delivery).unwrap();
3598 }
3599 lifecycle.render(&mut engine).unwrap();
3600 break;
3601 }
3602 assert!(Instant::now() < deadline);
3603 std::thread::yield_now();
3604 }
3605 assert_eq!(
3606 runtime.borrow().component_state.get(&path, "phase"),
3607 Some(&UiValue::String("loaded".to_owned()))
3608 );
3609 }
3610
3611 #[test]
3612 fn async_image_decode_completes_through_lifecycle_delivery() {
3613 let mut png = std::io::Cursor::new(Vec::new());
3614 image::DynamicImage::new_rgba8(1, 1)
3615 .write_to(&mut png, image::ImageFormat::Png)
3616 .unwrap();
3617 let mut engine = RuntimeEngine::new();
3618 let compiled = engine
3619 .compile(
3620 r#"
3621 fn init(ctx) {
3622 ctx.start_image_decode(
3623 asset("app/pixel"), Fn("loaded"), Fn("failed")
3624 );
3625 }
3626 fn loaded(ctx, handle) { ctx.set_state("image", handle); }
3627 fn failed(ctx, error) { () }
3628 fn view(ctx) { image(ctx.get_state("image")) }
3629 "#,
3630 )
3631 .unwrap();
3632 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3633 runtime
3634 .borrow()
3635 .assets
3636 .register(
3637 "app",
3638 InMemoryAssetProvider::new(BTreeMap::from([(
3639 "pixel".to_owned(),
3640 AssetData {
3641 mime_type: "image/png".to_owned(),
3642 bytes: png.into_inner(),
3643 },
3644 )])),
3645 )
3646 .unwrap();
3647 let path = ComponentInstancePath::root("App", "root");
3648 let schema = ComponentStateSchema::new(BTreeMap::from([(
3649 "image".to_owned(),
3650 StateField::new(
3651 ValueSchema::Handle {
3652 kind: "image".to_owned(),
3653 },
3654 UiValue::Handle(OpaqueHandle::new("image", 0)),
3655 ),
3656 )]))
3657 .unwrap();
3658 let mut lifecycle = ScriptLifecycle::new(
3659 compiled,
3660 Rc::clone(&runtime),
3661 path.clone(),
3662 None,
3663 BTreeMap::new(),
3664 &schema,
3665 )
3666 .unwrap();
3667 lifecycle.start(&mut engine).unwrap();
3668
3669 let assets = runtime.borrow().assets.clone();
3670 let deadline = Instant::now() + Duration::from_secs(1);
3671 loop {
3672 let deliveries = assets.drain_image_decodes(lifecycle.generation()).unwrap();
3673 if !deliveries.is_empty() {
3674 for delivery in deliveries {
3675 let _ = lifecycle.invoke_async_delivery(&engine, delivery).unwrap();
3676 }
3677 lifecycle.render(&mut engine).unwrap();
3678 break;
3679 }
3680 assert!(Instant::now() < deadline);
3681 std::thread::yield_now();
3682 }
3683 assert!(matches!(
3684 runtime.borrow().component_state.get(&path, "image"),
3685 Some(UiValue::Handle(handle)) if handle.id() != 0
3686 ));
3687 }
3688
3689 #[test]
3690 fn subscription_capability_streams_until_close() {
3691 struct Stream;
3692 impl SubscriptionCapabilityHandler for Stream {
3693 fn subscribe(&mut self, _: &str, _: UiValue) -> Result<SubscriptionWork, String> {
3694 Ok(SubscriptionWork::new(|emitter| {
3695 emitter.emit(UiValue::String("first".to_owned())).unwrap();
3696 emitter.emit(UiValue::String("second".to_owned())).unwrap();
3697 }))
3698 }
3699 }
3700
3701 let mut engine = RuntimeEngine::new();
3702 let compiled = engine.compile(&STREAM_COMPONENT_APP.replace(
3703 "fn view(ctx) { render_component(\"test/stream\", #{ key: \"stream\" }) }",
3704 "fn view(ctx) { if ctx.get_state(\"phase\")==\"hidden\" {text(\"hidden\")} else {render_component(\"test/stream\", #{key:\"stream\"})} }",
3705 )).unwrap();
3706 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3707 let capability = CapabilityId::parse("app.stream").unwrap();
3708 runtime
3709 .borrow_mut()
3710 .capabilities
3711 .register_subscription(
3712 CapabilityDescriptor {
3713 id: capability.clone(),
3714 version: Version::new(1, 0, 0),
3715 methods: BTreeMap::from([(
3716 "watch".to_owned(),
3717 CapabilityMethod {
3718 input: ValueSchema::Null,
3719 output: ValueSchema::string(),
3720 },
3721 )]),
3722 },
3723 Stream,
3724 )
3725 .unwrap();
3726 runtime
3727 .borrow_mut()
3728 .capabilities
3729 .activate(&BTreeMap::from([(capability, VersionReq::STAR)]))
3730 .unwrap();
3731 let path = ComponentInstancePath::root("App", "root");
3732 let mut lifecycle = ScriptLifecycle::new(
3733 compiled,
3734 Rc::clone(&runtime),
3735 path.clone(),
3736 None,
3737 BTreeMap::new(),
3738 &state_schema(),
3739 )
3740 .unwrap();
3741 lifecycle.start(&mut engine).unwrap();
3742
3743 let deadline = Instant::now() + Duration::from_secs(1);
3744 let mut already_drained = None;
3745 loop {
3746 let deliveries = runtime
3747 .borrow_mut()
3748 .subscriptions
3749 .drain(lifecycle.generation());
3750 for delivery in deliveries {
3751 already_drained = Some(delivery.clone());
3752 let _ = lifecycle.invoke_async_delivery(&engine, delivery).unwrap();
3753 }
3754 if runtime.borrow().subscriptions.active_count() == 0 {
3755 lifecycle.render(&mut engine).unwrap();
3756 break;
3757 }
3758 assert!(Instant::now() < deadline);
3759 std::thread::yield_now();
3760 }
3761 assert_eq!(
3762 runtime
3763 .borrow()
3764 .component_state
3765 .get(&path.child("StreamProbe", "stream"), "phase"),
3766 Some(&UiValue::String("second".to_owned()))
3767 );
3768 let late = already_drained.unwrap();
3769 runtime
3770 .borrow_mut()
3771 .set_component_state_from_host(&path, "phase", UiValue::String("hidden".to_owned()))
3772 .unwrap();
3773 lifecycle.render_dirty(&mut engine).unwrap();
3774 let callback = late.callback.clone();
3775 let _ = lifecycle.invoke_async_delivery(&engine, late).unwrap();
3776 assert!(
3777 lifecycle
3778 .invoke_callback(&engine, &callback, UiValue::Null)
3779 .is_err()
3780 );
3781 }
3782
3783 #[test]
3784 fn subscription_start_outside_a_declarative_effect_is_rejected() {
3785 let mut engine = RuntimeEngine::new();
3786 let compiled = engine
3787 .compile(
3788 r#"
3789 fn init(ctx) {
3790 ctx.start_subscription(
3791 "app.stream", "watch", (),
3792 Fn("received"), Fn("failed"), #{ delivery: "all" }
3793 );
3794 }
3795 fn received(ctx, value) { () }
3796 fn failed(ctx, error) { () }
3797 fn view(ctx) { text("never mounted") }
3798 "#,
3799 )
3800 .unwrap();
3801 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3802 let mut lifecycle = ScriptLifecycle::new(
3803 compiled,
3804 runtime,
3805 ComponentInstancePath::root("App", "root"),
3806 None,
3807 BTreeMap::new(),
3808 &state_schema(),
3809 )
3810 .unwrap();
3811 let error = lifecycle.start(&mut engine).unwrap_err().to_string();
3812 assert!(
3813 error.contains("subscriptions must be started by a declarative component effect"),
3814 "unexpected error: {error}"
3815 );
3816 }
3817
3818 fn assert_table_panel_marker(
3819 lifecycle: &ScriptLifecycle,
3820 runtime: &UiRuntimeState,
3821 track: bool,
3822 internal: bool,
3823 count: i32,
3824 ) {
3825 let root = lifecycle.root().unwrap();
3826 let panel = if track {
3827 root
3828 } else {
3829 let UiNodeKind::Box { children } = root.kind() else {
3830 panic!("expected track")
3831 };
3832 &children[0]
3833 };
3834 assert_eq!(
3835 panel.style().base.width,
3836 Some(crate::LayoutLength::Definite(
3837 crate::Length::pixels(123.0).unwrap()
3838 ))
3839 );
3840 if track {
3841 let Some(crate::table_layout::TableLayout::Columns(columns)) = panel.table_layout()
3842 else {
3843 panic!("track marker lost: internal={internal}, count={count}")
3844 };
3845 let plan = crate::table_layout::resolve(columns.as_slice(), 400.0, &runtime.signals);
3846 assert_eq!(
3847 plan.widths,
3848 vec![120.0 + if internal { f64::from(count) } else { 0.0 }, 140.0]
3849 );
3850 } else {
3851 assert_eq!(
3852 panel.table_column(),
3853 Some(if internal {
3854 usize::try_from(count % 2).unwrap()
3855 } else {
3856 0
3857 })
3858 );
3859 }
3860 let UiNodeKind::Box { children } = panel.kind() else {
3861 panic!("expected panel")
3862 };
3863 assert!(
3864 matches!(children[0].kind(), UiNodeKind::Text { text } if text.as_str() == count.to_string())
3865 );
3866 }
3867
3868 fn assert_table_modifier_replay(track: bool) {
3869 for internal in [true, false] {
3870 let modifier = if track {
3871 ".with_table_track(columns(120), [])"
3872 } else {
3873 ".with_table_column(0)"
3874 };
3875 let internal_modifier = if track {
3876 ".with_table_track(columns(120 + count), [])"
3877 } else {
3878 ".with_table_column(count % 2)"
3879 };
3880 let panel = format!(
3881 "render_component(\"tests/table_panel\", #{{key:\"panel\"}}){}.with_style(style().width(px(123)))",
3882 if internal { "" } else { modifier }
3883 );
3884 let source = r#"
3885 define_component(#{
3886 metadata:#{id:"tests/table_panel","export":"Panel",version:"0.1.8",
3887 runtime_api:#{min_inclusive:2,max_exclusive:3},dependencies:[],capabilities:#{}},
3888 schema:#{props:#{key:#{schema:#{type:"string"},required:true,sensitive:false}},
3889 state:#{fields:#{count:#{schema:#{type:"integer"},"default":#{type:"integer",value:0}}}},
3890 events:#{},slots:#{},parts:[]},render:Fn("render_panel")
3891 });
3892 fn columns(width) { [#{key:"a",width:#{kind:"fixed",value:width}},
3893 #{key:"b",width:#{kind:"fixed",value:140}}] }
3894 fn render_panel(ctx, props) {
3895 let count = ctx.get_state("count");
3896 if count < 0 { throw "rejected panel candidate"; }
3897 column([text(`${count}`)])INTERNAL
3898 }
3899 fn view(ctx) { VIEW }
3900 "#
3901 .replace("INTERNAL", if internal { internal_modifier } else { "" })
3902 .replace(
3903 "VIEW",
3904 &if track { panel } else { format!("column([{panel}]).with_table_track(columns(120), [])") },
3905 );
3906 let mut engine = RuntimeEngine::new();
3907 let compiled = engine
3908 .compile_named("table-modifier.rhai", &source)
3909 .unwrap();
3910 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
3911 let root = ComponentInstancePath::root("App", format!("table-{track}-{internal}"));
3912 let component = root.child("Panel", "panel");
3913 let mut lifecycle = ScriptLifecycle::new(
3914 compiled,
3915 Rc::clone(&runtime),
3916 root,
3917 Some("main".into()),
3918 BTreeMap::new(),
3919 &ComponentStateSchema::default(),
3920 )
3921 .unwrap();
3922 lifecycle.start(&mut engine).unwrap();
3923 assert_table_panel_marker(&lifecycle, &runtime.borrow(), track, internal, 0);
3924 for count in [1, 2] {
3925 runtime
3926 .borrow_mut()
3927 .set_component_state_from_host(
3928 &component,
3929 "count",
3930 UiValue::Integer(i64::from(count)),
3931 )
3932 .unwrap();
3933 assert_eq!(
3934 runtime.borrow().dirty_components(),
3935 &BTreeSet::from([component.clone()])
3936 );
3937 assert!(lifecycle.render_dirty(&mut engine).unwrap());
3938 assert_table_panel_marker(&lifecycle, &runtime.borrow(), track, internal, count);
3939 }
3940 runtime
3941 .borrow_mut()
3942 .set_component_state_from_host(&component, "count", UiValue::Integer(-1))
3943 .unwrap();
3944 assert!(lifecycle.render_dirty(&mut engine).is_err());
3945 assert_table_panel_marker(&lifecycle, &runtime.borrow(), track, internal, 2);
3946 runtime
3947 .borrow_mut()
3948 .set_component_state_from_host(&component, "count", UiValue::Integer(3))
3949 .unwrap();
3950 assert!(lifecycle.render_dirty(&mut engine).unwrap());
3951 assert_table_panel_marker(&lifecycle, &runtime.borrow(), track, internal, 3);
3952 }
3953 }
3954
3955 #[test]
3956 fn table_modifiers_replay_track_across_incremental_updates_and_failed_candidates() {
3957 assert_table_modifier_replay(true);
3958 }
3959
3960 #[test]
3961 fn table_modifiers_replay_column_across_incremental_updates_and_failed_candidates() {
3962 assert_table_modifier_replay(false);
3963 }
3964}