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