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