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