1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::rc::Rc;
4
5use rhai::Dynamic;
6use thiserror::Error;
7
8use crate::{
9 AnimationError, AsyncDelivery, AsyncScope, CompiledUi, ComponentInstancePath,
10 ComponentStateSchema, EventSchema, ExecutionPhase, 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 retained.reconcile(root.clone())?;
211 self.validate_resource_budgets(engine, &retained)?;
212 self.reconcile_animations(&root)?;
213 self.reconcile_effects(
214 engine,
215 &self.compiled,
216 runtime_snapshot.component_state().clone(),
217 &retained,
218 )?;
219 self.retain_geometry_nodes(&retained)?;
220 self.validate_signal_bindings(&root)?;
221 Ok((root, retained))
222 })();
223 match result {
224 Ok((root, retained)) => {
225 self.trace_reconcile("full", retained.last_report());
226 self.retained = retained;
227 self.state = LifecycleState::Running;
228 self.root = Some(Rc::new(root));
229 self.commit_runtime_transaction()?;
230 self.root.as_deref().ok_or(LifecycleError::MissingRoot)
231 }
232 Err(error) => {
233 self.runtime
234 .try_borrow_mut()
235 .map_err(|_| LifecycleError::Borrowed)?
236 .restore(runtime_snapshot)?;
237 engine.restore_execution_checkpoint(engine_checkpoint);
238 Err(error)
239 }
240 }
241 }
242
243 pub fn render_dirty(&mut self, engine: &mut RuntimeEngine) -> Result<bool, LifecycleError> {
253 if !self
254 .runtime
255 .try_borrow()
256 .map_err(|_| LifecycleError::Borrowed)?
257 .has_window_dirty(&self.root_path)
258 {
259 return Ok(false);
260 }
261 let runtime_snapshot = self.begin_runtime_transaction()?;
262 let engine_checkpoint = engine.execution_checkpoint();
263 let dirty = self
264 .runtime
265 .try_borrow_mut()
266 .map_err(|_| LifecycleError::Borrowed)?
267 .take_window_dirty_components(&self.root_path);
268 debug_assert!(!dirty.is_empty());
269 let topmost = topmost_paths(&dirty);
270 if topmost.iter().any(|path| path == &self.root_path)
271 || topmost.iter().any(|path| {
272 engine
273 .component_invocations()
274 .all(|recipe| recipe.path() != path)
275 })
276 {
277 return self.render_dirty_full(engine, &dirty, runtime_snapshot, engine_checkpoint);
278 }
279
280 let mut root = self
281 .root
282 .as_deref()
283 .cloned()
284 .ok_or(LifecycleError::MissingRoot)?;
285 let active = engine
286 .component_invocations()
287 .map(|recipe| recipe.path().clone())
288 .collect::<BTreeSet<_>>();
289 let Some(topmost) = replaceable_component_paths(&root, &self.root_path, topmost, &active)
290 else {
291 return self.render_dirty_full(engine, &dirty, runtime_snapshot, engine_checkpoint);
292 };
293 engine.begin_execution_session();
294 let mut retained = self.retained.clone();
295 let result = (|| {
296 for path in topmost {
297 let subtree = engine.rerender_component(&path, &root, &dirty)?;
298 if !root.replace_component_subtree(&path, subtree) {
299 return Err(LifecycleError::MissingComponentSubtree(path));
300 }
301 }
302 retained.reconcile(root.clone())?;
303 self.validate_resource_budgets(engine, &retained)?;
304 self.reconcile_animations(&root)?;
305 self.reconcile_effects(
306 engine,
307 &self.compiled,
308 runtime_snapshot.component_state().clone(),
309 &retained,
310 )?;
311 self.retain_geometry_nodes(&retained)?;
312 self.validate_signal_bindings(&root)?;
313 Ok(())
314 })();
315 match result {
316 Ok(()) => {
317 self.trace_reconcile("incremental", retained.last_report());
318 self.root = Some(Rc::new(root));
319 self.retained = retained;
320 self.state = LifecycleState::Running;
321 self.commit_runtime_transaction()?;
322 Ok(true)
323 }
324 Err(error) => {
325 self.runtime
326 .try_borrow_mut()
327 .map_err(|_| LifecycleError::Borrowed)?
328 .restore(runtime_snapshot)?;
329 engine.restore_execution_checkpoint(engine_checkpoint);
330 Err(error)
331 }
332 }
333 }
334
335 fn render_dirty_full(
336 &mut self,
337 engine: &mut RuntimeEngine,
338 dirty: &BTreeSet<ComponentInstancePath>,
339 runtime_snapshot: crate::UiStateSnapshot,
340 engine_checkpoint: crate::engine::RuntimeEngineCheckpoint,
341 ) -> Result<bool, LifecycleError> {
342 match self.render_impl(engine, Some(dirty)) {
343 Ok(_) => {
344 self.commit_runtime_transaction()?;
345 Ok(true)
346 }
347 Err(error) => {
348 self.runtime
349 .try_borrow_mut()
350 .map_err(|_| LifecycleError::Borrowed)?
351 .restore(runtime_snapshot)?;
352 engine.restore_execution_checkpoint(engine_checkpoint);
353 Err(error)
354 }
355 }
356 }
357
358 pub fn realize_virtual_requests(
364 &mut self,
365 engine: &mut RuntimeEngine,
366 ) -> Result<bool, LifecycleError> {
367 if !self
368 .runtime
369 .try_borrow()
370 .map_err(|_| LifecycleError::Borrowed)?
371 .virtual_requests
372 .has_scope(&self.root_path)
373 {
374 return Ok(false);
375 }
376 let runtime_snapshot = self.begin_runtime_transaction()?;
377 let engine_checkpoint = engine.execution_checkpoint();
378 let requests = self
379 .runtime
380 .try_borrow()
381 .map_err(|_| LifecycleError::Borrowed)?
382 .virtual_requests
383 .drain();
384 let mut selected = Vec::new();
385 for (id, indices) in requests {
386 if id.component.is_within(&self.root_path) {
387 selected.push((id, indices));
388 } else {
389 self.runtime
390 .try_borrow()
391 .map_err(|_| LifecycleError::Borrowed)?
392 .virtual_requests
393 .request(id, indices);
394 }
395 }
396 if selected.is_empty() {
397 return Ok(false);
398 }
399 engine.begin_execution_session();
400 let mut root = self
401 .root
402 .as_deref()
403 .cloned()
404 .ok_or(LifecycleError::MissingRoot)?;
405 let mut retained = self.retained.clone();
406 let result = (|| {
407 let mut changed = false;
408 for (id, indices) in selected {
409 let mut items = root
410 .virtual_collection_items(&id)
411 .cloned()
412 .ok_or_else(|| LifecycleError::MissingVirtualCollection(id.clone()))?;
413 let missing = indices
414 .iter()
415 .filter(|index| !items.contains_key(index))
416 .copied()
417 .collect::<BTreeSet<_>>();
418 let previous_indices = items.keys().copied().collect::<BTreeSet<_>>();
419 if missing.is_empty() && previous_indices == indices {
420 continue;
421 }
422 items.retain(|index, _| indices.contains(index));
423 if !missing.is_empty() {
424 items.extend(engine.realize_virtual_collection(&id, &missing)?);
425 }
426 if !root.replace_virtual_collection_items(&id, items.clone()) {
427 return Err(LifecycleError::MissingVirtualCollection(id));
428 }
429 engine.update_virtual_collection_snapshot(&id, &items)?;
430 changed = true;
431 }
432 if !changed {
433 return Ok(false);
434 }
435 retained.reconcile(root.clone())?;
436 self.validate_resource_budgets(engine, &retained)?;
437 self.reconcile_animations(&root)?;
438 self.reconcile_effects(
439 engine,
440 &self.compiled,
441 runtime_snapshot.component_state().clone(),
442 &retained,
443 )?;
444 self.retain_geometry_nodes(&retained)?;
445 self.validate_signal_bindings(&root)?;
446 Ok(true)
447 })();
448 match result {
449 Ok(changed) => {
450 if changed {
451 self.trace_reconcile("virtual", retained.last_report());
452 self.root = Some(Rc::new(root));
453 self.retained = retained;
454 }
455 self.commit_runtime_transaction()?;
456 Ok(changed)
457 }
458 Err(error) => {
459 self.runtime
460 .try_borrow_mut()
461 .map_err(|_| LifecycleError::Borrowed)?
462 .restore(runtime_snapshot)?;
463 engine.restore_execution_checkpoint(engine_checkpoint);
464 Err(error)
465 }
466 }
467 }
468
469 pub fn dispose(&mut self, engine: &mut RuntimeEngine) -> Result<(), LifecycleError> {
476 if !matches!(
477 self.state,
478 LifecycleState::Initialized | LifecycleState::Running | LifecycleState::Suspended
479 ) {
480 return Err(LifecycleError::InvalidTransition {
481 from: self.state,
482 operation: "dispose",
483 });
484 }
485 let snapshot = self.begin_runtime_transaction()?;
486 let result = (|| {
487 self.reconcile_effect_candidate(
488 engine,
489 &self.compiled,
490 RetainedDeclarations::default(),
491 None,
492 )?;
493 let context = self.context(ExecutionPhase::Dispose);
494 engine.call_optional_lifecycle(&self.compiled, "dispose", context)?;
495 Ok(())
496 })();
497 match result {
498 Ok(()) => {
499 self.state = LifecycleState::Disposed;
500 self.commit_runtime_transaction()?;
501 Ok(())
502 }
503 Err(error) => {
504 self.runtime
505 .try_borrow_mut()
506 .map_err(|_| LifecycleError::Borrowed)?
507 .restore(snapshot)?;
508 Err(error)
509 }
510 }
511 }
512
513 pub fn suspend(&mut self, engine: &mut RuntimeEngine) -> Result<bool, LifecycleError> {
521 if self.state == LifecycleState::Suspended {
522 return Ok(false);
523 }
524 if self.state != LifecycleState::Running {
525 return Err(LifecycleError::InvalidTransition {
526 from: self.state,
527 operation: "suspend",
528 });
529 }
530 let snapshot = self.begin_runtime_transaction()?;
531 let checkpoint = engine.execution_checkpoint();
532 let result = (|| {
533 engine.call_optional_lifecycle(
534 &self.compiled,
535 "suspend",
536 self.context(ExecutionPhase::Suspend),
537 )?;
538 self.stop_active_effects(engine)?;
539 let mut runtime = self
540 .runtime
541 .try_borrow_mut()
542 .map_err(|_| LifecycleError::Borrowed)?;
543 let now = runtime.clock.now();
544 runtime.timers.pause_component_scope(&self.root_path, now);
545 runtime.animation_values = runtime.animations.snapshot(now);
546 runtime
547 .pointer_capture_for(self.presentation_scope())
548 .clear();
549 Ok(now)
550 })();
551 match result {
552 Ok(now) => {
553 self.suspended_at = Some(now);
554 self.state = LifecycleState::Suspended;
555 self.commit_runtime_transaction()?;
556 Ok(true)
557 }
558 Err(error) => {
559 self.runtime
560 .try_borrow_mut()
561 .map_err(|_| LifecycleError::Borrowed)?
562 .restore(snapshot)?;
563 engine.restore_execution_checkpoint(checkpoint);
564 Err(error)
565 }
566 }
567 }
568
569 pub fn resume(&mut self, engine: &mut RuntimeEngine) -> Result<bool, LifecycleError> {
578 if self.state == LifecycleState::Running {
579 return Ok(false);
580 }
581 if self.state != LifecycleState::Suspended {
582 return Err(LifecycleError::InvalidTransition {
583 from: self.state,
584 operation: "resume",
585 });
586 }
587 let snapshot = self.begin_runtime_transaction()?;
588 let checkpoint = engine.execution_checkpoint();
589 let suspended_at = self
590 .suspended_at
591 .ok_or(LifecycleError::MissingSuspendTime)?;
592 let now = self
593 .runtime
594 .try_borrow()
595 .map_err(|_| LifecycleError::Borrowed)?
596 .clock
597 .now();
598 let elapsed = now.saturating_duration_since(suspended_at);
599 let result = (|| {
600 {
601 let mut runtime = self
602 .runtime
603 .try_borrow_mut()
604 .map_err(|_| LifecycleError::Borrowed)?;
605 runtime.timers.resume_component_scope(&self.root_path, now);
606 runtime
607 .animations
608 .delay_node_scope(&self.animation_root_path(), elapsed);
609 runtime.animation_values = runtime.animations.snapshot(now);
610 }
611 engine.call_optional_lifecycle_with_value(
612 &self.compiled,
613 "resume",
614 self.context(ExecutionPhase::Resume),
615 UiValue::Integer(i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)),
616 )?;
617 self.state = LifecycleState::Initialized;
618 self.render_impl(engine, None)?;
619 Ok(())
620 })();
621 match result {
622 Ok(()) => {
623 self.suspended_at = None;
624 self.state = LifecycleState::Running;
625 self.commit_runtime_transaction()?;
626 Ok(true)
627 }
628 Err(error) => {
629 self.state = LifecycleState::Suspended;
630 self.runtime
631 .try_borrow_mut()
632 .map_err(|_| LifecycleError::Borrowed)?
633 .restore(snapshot)?;
634 engine.restore_execution_checkpoint(checkpoint);
635 Err(error)
636 }
637 }
638 }
639
640 fn stop_active_effects(&self, engine: &RuntimeEngine) -> Result<(), LifecycleError> {
641 let plan = self
642 .runtime
643 .try_borrow()
644 .map_err(|_| LifecycleError::Borrowed)?
645 .effects
646 .plan(&self.root_path, BTreeMap::new());
647 if plan.transition_count() > 64 {
648 return Err(LifecycleError::EffectBudget(plan.transition_count()));
649 }
650 for (descriptor, scope) in plan.cleanup_descriptors() {
651 self.invoke_effect_callback(
652 engine,
653 &self.compiled,
654 descriptor.cleanup(),
655 descriptor.dependencies().clone(),
656 scope,
657 )?;
658 }
659 let mut runtime = self
660 .runtime
661 .try_borrow_mut()
662 .map_err(|_| LifecycleError::Borrowed)?;
663 for (_, scope) in plan.cleanup_descriptors() {
664 runtime.cancel_async_scope(&scope)?;
665 }
666 runtime.effects.commit(plan);
667 Ok(())
668 }
669
670 pub fn invoke_callback(
676 &self,
677 engine: &RuntimeEngine,
678 callback: &ScriptCallback,
679 payload: UiValue,
680 ) -> Result<Dynamic, LifecycleError> {
681 self.invoke_callback_with_event_target(engine, callback, payload, None)
682 }
683
684 pub(crate) fn invoke_callback_with_event_target(
685 &self,
686 engine: &RuntimeEngine,
687 callback: &ScriptCallback,
688 payload: UiValue,
689 event_target: Option<crate::GeometryBounds>,
690 ) -> Result<Dynamic, LifecycleError> {
691 self.validate_callback_owner(callback)?;
692 let root_context = self.context(ExecutionPhase::Event);
693 let context = callback
694 .component()
695 .map_or(root_context.clone(), |component| {
696 root_context.for_component(component.clone(), callback.events().clone())
697 })
698 .with_native_context(callback.native_context().cloned())
699 .with_event_target(event_target);
700 Ok(engine.invoke_callback(&self.compiled, callback, (context, payload.into_dynamic()))?)
701 }
702
703 pub fn invoke_callback_transactional(
710 &self,
711 engine: &RuntimeEngine,
712 callback: &ScriptCallback,
713 payload: UiValue,
714 ) -> Result<Dynamic, LifecycleError> {
715 let snapshot = self.begin_runtime_transaction()?;
716 match self.invoke_callback(engine, callback, payload) {
717 Ok(value) => {
718 self.commit_runtime_transaction()?;
719 Ok(value)
720 }
721 Err(error) => {
722 self.runtime
723 .try_borrow_mut()
724 .map_err(|_| LifecycleError::Borrowed)?
725 .restore(snapshot)?;
726 Err(error)
727 }
728 }
729 }
730
731 pub fn invoke_async_delivery(
737 &self,
738 engine: &RuntimeEngine,
739 delivery: AsyncDelivery,
740 ) -> Result<Dynamic, LifecycleError> {
741 self.validate_callback_owner(&delivery.callback)?;
742 let component = delivery
743 .callback
744 .component()
745 .cloned()
746 .or_else(|| delivery.scope.component().cloned())
747 .unwrap_or_else(|| self.root_path.clone());
748 let events = delivery.callback.events().clone();
749 let scope = delivery.scope.clone();
750 let context = UiContext::new(
751 Rc::clone(&self.runtime),
752 component,
753 self.window.clone(),
754 ExecutionPhase::Event,
755 events,
756 )
757 .with_optional_view_id(self.view.clone())
758 .with_generation(self.compiled.generation())
759 .with_native_context(delivery.callback.native_context().cloned())
760 .with_async_scope(scope);
761 Ok(engine.invoke_callback(
762 &self.compiled,
763 &delivery.callback,
764 (context, delivery.payload.into_dynamic()),
765 )?)
766 }
767
768 pub fn invoke_async_delivery_transactional(
774 &self,
775 engine: &RuntimeEngine,
776 delivery: AsyncDelivery,
777 ) -> Result<Dynamic, LifecycleError> {
778 let snapshot = self.begin_runtime_transaction()?;
779 match self.invoke_async_delivery(engine, delivery) {
780 Ok(value) => {
781 self.commit_runtime_transaction()?;
782 Ok(value)
783 }
784 Err(error) => {
785 self.runtime
786 .try_borrow_mut()
787 .map_err(|_| LifecycleError::Borrowed)?
788 .restore(snapshot)?;
789 Err(error)
790 }
791 }
792 }
793
794 pub fn invoke_component_event_transactional(
801 &self,
802 engine: &RuntimeEngine,
803 pending: crate::PendingEvent,
804 ) -> Result<Option<Dynamic>, LifecycleError> {
805 let callback = self
806 .runtime
807 .try_borrow()
808 .map_err(|_| LifecycleError::Borrowed)?
809 .component_event_handler(&pending.target, &pending.event.name);
810 callback
811 .map(|callback| {
812 self.invoke_callback_transactional(engine, &callback, pending.event.payload)
813 })
814 .transpose()
815 }
816
817 pub fn reload(
826 &mut self,
827 engine: &mut RuntimeEngine,
828 candidate: CompiledUi,
829 state_schema: &ComponentStateSchema,
830 ) -> Result<&UiNode, LifecycleError> {
831 if self.state != LifecycleState::Running {
832 return Err(LifecycleError::InvalidTransition {
833 from: self.state,
834 operation: "reload",
835 });
836 }
837 let snapshot = self.begin_runtime_transaction()?;
838 let engine_checkpoint = engine.execution_checkpoint();
839 let result: Result<(UiNode, crate::RetainedUiTree), LifecycleError> = (|| {
840 self.runtime
841 .try_borrow_mut()
842 .map_err(|_| LifecycleError::Borrowed)?
843 .component_state
844 .mount_instance(self.root_path.clone(), state_schema)?;
845 engine.call_optional_lifecycle(
846 &candidate,
847 "init",
848 self.context_for(ExecutionPhase::Init, candidate.generation()),
849 )?;
850 let root = engine.render_with_context_staged(
851 &candidate,
852 self.context_for(ExecutionPhase::Render, candidate.generation()),
853 )?;
854 let mut retained = self.retained.clone();
855 retained.reconcile(root.clone())?;
856 self.validate_resource_budgets(engine, &retained)?;
857 self.reconcile_animations(&root)?;
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
946 .animations
947 .delay_node_scope(&self.animation_root_path(), elapsed);
948 runtime.animation_values = runtime.animations.snapshot(now);
949 }
950 let root = engine.render_with_context_staged(
951 &candidate,
952 self.context_for(ExecutionPhase::Render, candidate.generation()),
953 )?;
954 let mut retained = self.retained.clone();
955 retained.reconcile(root.clone())?;
956 self.validate_resource_budgets(engine, &retained)?;
957 self.reconcile_animations(&root)?;
958 self.reconcile_effects(
959 engine,
960 &candidate,
961 snapshot.component_state().clone(),
962 &retained,
963 )?;
964 {
965 let mut runtime = self
966 .runtime
967 .try_borrow_mut()
968 .map_err(|_| LifecycleError::Borrowed)?;
969 runtime.timers.resume_component_scope(&self.root_path, now);
970 }
971 self.retain_geometry_nodes(&retained)?;
972 self.validate_signal_bindings(&root)?;
973 Ok((root, retained))
974 })();
975 match result {
976 Ok((root, retained)) => {
977 self.trace_reconcile("resume_reload", retained.last_report());
978 self.compiled = candidate;
979 self.retained = retained;
980 self.root = Some(Rc::new(root));
981 self.suspended_at = None;
982 self.state = LifecycleState::Running;
983 self.commit_runtime_transaction()?;
984 self.root.as_deref().ok_or(LifecycleError::MissingRoot)
985 }
986 Err(error) => {
987 self.runtime
988 .try_borrow_mut()
989 .map_err(|_| LifecycleError::Borrowed)?
990 .restore(snapshot)?;
991 engine.restore_execution_checkpoint(engine_checkpoint);
992 self.state = LifecycleState::Suspended;
993 Err(error)
994 }
995 }
996 }
997
998 pub fn start(&mut self, engine: &mut RuntimeEngine) -> Result<&UiNode, LifecycleError> {
1004 let runtime_snapshot = self.begin_runtime_transaction()?;
1005 let engine_checkpoint = engine.execution_checkpoint();
1006 let lifecycle_checkpoint = self.execution_checkpoint();
1007 let result = (|| {
1008 self.initialize(engine)?;
1009 self.render(engine)?;
1010 Ok::<_, LifecycleError>(())
1011 })();
1012 match result {
1013 Ok(()) => {
1014 self.commit_runtime_transaction()?;
1015 self.root.as_deref().ok_or(LifecycleError::MissingRoot)
1016 }
1017 Err(error) => {
1018 self.runtime
1019 .try_borrow_mut()
1020 .map_err(|_| LifecycleError::Borrowed)?
1021 .restore(runtime_snapshot)?;
1022 engine.restore_execution_checkpoint(engine_checkpoint);
1023 self.restore_execution_checkpoint(lifecycle_checkpoint);
1024 Err(error)
1025 }
1026 }
1027 }
1028
1029 fn context(&self, phase: ExecutionPhase) -> UiContext {
1030 self.context_for(phase, self.compiled.generation())
1031 }
1032
1033 fn reconcile_animations(&self, root: &UiNode) -> Result<(), LifecycleError> {
1034 let mut runtime = self
1035 .runtime
1036 .try_borrow_mut()
1037 .map_err(|_| LifecycleError::Borrowed)?;
1038 let now = runtime.clock.now();
1039 let values = crate::animation::reconcile_node_animations_scoped(
1040 root,
1041 &mut runtime.animations,
1042 now,
1043 &self.animation_root_path(),
1044 )?;
1045 runtime.animation_values = values;
1046 Ok(())
1047 }
1048
1049 fn validate_resource_budgets(
1050 &self,
1051 engine: &RuntimeEngine,
1052 retained: &crate::RetainedUiTree,
1053 ) -> Result<(), LifecycleError> {
1054 let budgets = self
1055 .runtime
1056 .try_borrow()
1057 .map_err(|_| LifecycleError::Borrowed)?
1058 .budgets
1059 .clone();
1060 budgets.validate()?;
1061 crate::RuntimeBudgets::check("retained_nodes", retained.len(), budgets.retained_nodes)?;
1062 let handlers = retained.nodes().fold(0usize, |total, node| {
1063 total.saturating_add(node.handler_count())
1064 });
1065 crate::RuntimeBudgets::check("event_handlers", handlers, budgets.event_handlers)?;
1066 let layers = retained
1067 .nodes()
1068 .filter(|node| node.kind() == crate::UiNodeKindTag::Layer)
1069 .count();
1070 crate::RuntimeBudgets::check("layers", layers, budgets.layers)?;
1071 let canvas_scenes = retained
1072 .nodes()
1073 .filter(|node| node.kind() == crate::UiNodeKindTag::Canvas)
1074 .count();
1075 crate::RuntimeBudgets::check("canvas_scenes", canvas_scenes, budgets.canvas_scenes)?;
1076 let canvas_commands = retained.nodes().fold(0usize, |total, node| {
1077 total.saturating_add(node.canvas_command_count())
1078 });
1079 crate::RuntimeBudgets::check("canvas_commands", canvas_commands, budgets.canvas_commands)?;
1080 let virtual_data = retained.nodes().fold(0usize, |total, node| {
1081 total.saturating_add(node.virtual_data_item_count())
1082 });
1083 let virtual_realized = retained.nodes().fold(0usize, |total, node| {
1084 total.saturating_add(node.virtual_realized_item_count())
1085 });
1086 crate::RuntimeBudgets::check(
1087 "virtual_data_items",
1088 virtual_data,
1089 budgets.virtual_data_items,
1090 )?;
1091 crate::RuntimeBudgets::check(
1092 "virtual_realized_items",
1093 virtual_realized,
1094 budgets.virtual_realized_items,
1095 )?;
1096 crate::RuntimeBudgets::check(
1097 "formal_components",
1098 engine.component_invocations().len(),
1099 budgets.formal_components,
1100 )?;
1101 crate::RuntimeBudgets::check(
1102 "effects",
1103 engine.component_effects_in_scope(&self.root_path).len(),
1104 budgets.effects,
1105 )?;
1106 crate::RuntimeBudgets::check(
1107 "signals",
1108 engine.component_signals_in_scope(&self.root_path).len(),
1109 budgets.signals,
1110 )?;
1111 crate::RuntimeBudgets::check(
1112 "element_refs",
1113 engine
1114 .component_element_refs_in_scope(&self.root_path)
1115 .len(),
1116 budgets.element_refs,
1117 )?;
1118 crate::RuntimeBudgets::check(
1119 "timers",
1120 engine.component_timers_in_scope(&self.root_path).len(),
1121 budgets.timers,
1122 )?;
1123 Ok(())
1124 }
1125
1126 fn reconcile_effects(
1127 &self,
1128 engine: &mut RuntimeEngine,
1129 candidate: &CompiledUi,
1130 previous_state: crate::StateStore,
1131 retained: &crate::RetainedUiTree,
1132 ) -> Result<(), LifecycleError> {
1133 let declarations = RetainedDeclarations {
1134 effects: engine.component_effects_in_scope(&self.root_path),
1135 timers: engine.component_timers_in_scope(&self.root_path),
1136 signals: engine.component_signals_in_scope(&self.root_path),
1137 element_refs: self.element_ref_bindings(engine, retained)?,
1138 };
1139 self.reconcile_effect_candidate(engine, candidate, declarations, Some(previous_state))
1140 }
1141
1142 fn reconcile_effect_candidate(
1143 &self,
1144 engine: &mut RuntimeEngine,
1145 candidate: &CompiledUi,
1146 declarations: RetainedDeclarations,
1147 previous_state: Option<crate::StateStore>,
1148 ) -> Result<(), LifecycleError> {
1149 let plan = self
1150 .runtime
1151 .try_borrow()
1152 .map_err(|_| LifecycleError::Borrowed)?
1153 .effects
1154 .plan(&self.root_path, declarations.effects);
1155 let transition_count = plan.transition_count();
1156 if transition_count > 64 {
1157 return Err(LifecycleError::EffectBudget(transition_count));
1158 }
1159 if plan.has_cleanup()
1160 && let Some(previous_state) = previous_state
1161 {
1162 self.runtime
1163 .try_borrow_mut()
1164 .map_err(|_| LifecycleError::Borrowed)?
1165 .component_state = previous_state;
1166 }
1167 for (descriptor, scope) in plan.cleanup_descriptors() {
1168 self.invoke_effect_callback(
1169 engine,
1170 candidate,
1171 descriptor.cleanup(),
1172 descriptor.dependencies().clone(),
1173 scope,
1174 )?;
1175 }
1176 let virtual_collections = engine.virtual_collection_ids_in_scope(&self.root_path);
1177 {
1178 let mut runtime = self
1179 .runtime
1180 .try_borrow_mut()
1181 .map_err(|_| LifecycleError::Borrowed)?;
1182 let now = runtime.clock.now();
1183 engine.commit_component_renders(&mut runtime)?;
1184 runtime
1185 .timers
1186 .reconcile(&self.root_path, declarations.timers, now);
1187 runtime
1188 .signals
1189 .reconcile(&self.root_path, declarations.signals);
1190 let geometry_readers = runtime
1191 .element_refs
1192 .reconcile(&self.root_path, declarations.element_refs);
1193 let geometry = runtime.geometry_for(self.presentation_scope());
1194 for (node, readers) in geometry_readers {
1195 geometry.register_readers(node, readers);
1196 }
1197 runtime.virtual_requests.retain(&virtual_collections);
1198 }
1199 for (descriptor, scope) in plan.start_descriptors() {
1200 self.invoke_effect_callback(
1201 engine,
1202 candidate,
1203 descriptor.start(),
1204 descriptor.dependencies().clone(),
1205 scope,
1206 )?;
1207 }
1208 {
1209 let mut runtime = self
1210 .runtime
1211 .try_borrow_mut()
1212 .map_err(|_| LifecycleError::Borrowed)?;
1213 for (_, scope) in plan.cleanup_descriptors() {
1214 runtime.cancel_async_scope(&scope)?;
1215 }
1216 runtime.effects.commit(plan);
1217 }
1218 Ok(())
1219 }
1220
1221 fn invoke_effect_callback(
1222 &self,
1223 engine: &RuntimeEngine,
1224 candidate: &CompiledUi,
1225 callback: &ScriptCallback,
1226 dependencies: UiValue,
1227 scope: AsyncScope,
1228 ) -> Result<(), LifecycleError> {
1229 self.validate_callback_owner(callback)?;
1230 let compiled = if callback.generation() == candidate.generation() {
1231 candidate
1232 } else if callback.generation() == self.compiled.generation() {
1233 &self.compiled
1234 } else {
1235 return Err(LifecycleError::StaleEffect {
1236 name: callback.name().to_owned(),
1237 generation: callback.generation(),
1238 });
1239 };
1240 let root_context = self.context_for(ExecutionPhase::Event, compiled.generation());
1241 let context = callback
1242 .component()
1243 .map_or(root_context.clone(), |component| {
1244 root_context.for_component(component.clone(), callback.events().clone())
1245 })
1246 .with_native_context(callback.native_context().cloned())
1247 .with_async_scope(scope);
1248 let _ = engine.invoke_callback_for_generation(
1249 compiled,
1250 callback,
1251 (context, dependencies.into_dynamic()),
1252 )?;
1253 Ok(())
1254 }
1255
1256 fn validate_callback_owner(&self, callback: &ScriptCallback) -> Result<(), LifecycleError> {
1257 let (Some(component), Some(incarnation)) = (callback.component(), callback.incarnation())
1258 else {
1259 return Ok(());
1260 };
1261 let active = self
1262 .runtime
1263 .try_borrow()
1264 .map_err(|_| LifecycleError::Borrowed)?
1265 .component_incarnation(component);
1266 if active == Some(incarnation) {
1267 Ok(())
1268 } else {
1269 Err(RuntimeError::StaleComponentCallback {
1270 name: callback.name().to_owned(),
1271 component: component.clone(),
1272 }
1273 .into())
1274 }
1275 }
1276
1277 fn validate_signal_bindings(&self, root: &UiNode) -> Result<(), LifecycleError> {
1278 let runtime = self
1279 .runtime
1280 .try_borrow()
1281 .map_err(|_| LifecycleError::Borrowed)?;
1282 let mut pending = vec![root];
1283 while let Some(node) = pending.pop() {
1284 for (_, signal) in node.signal_bindings() {
1285 let _ = runtime.signals.read(signal)?;
1286 }
1287 for (_, children) in node.retained_child_groups() {
1288 pending.extend(children);
1289 }
1290 }
1291 Ok(())
1292 }
1293
1294 fn element_ref_bindings(
1295 &self,
1296 engine: &RuntimeEngine,
1297 retained: &crate::RetainedUiTree,
1298 ) -> Result<BTreeMap<crate::ElementRefId, crate::NodeId>, LifecycleError> {
1299 let declared = engine.component_element_refs_in_scope(&self.root_path);
1300 let mut bindings = BTreeMap::new();
1301 for node in retained.nodes() {
1302 let Some(reference) = node.element_ref() else {
1303 continue;
1304 };
1305 if node.key().is_none() {
1306 return Err(crate::ElementRefError::MissingNodeKey(reference.id().clone()).into());
1307 }
1308 if !declared.contains(reference.id()) {
1309 return Err(crate::ElementRefError::Undeclared(reference.id().clone()).into());
1310 }
1311 if bindings.insert(reference.id().clone(), node.id()).is_some() {
1312 return Err(
1313 crate::ElementRefError::DuplicateBinding(reference.id().clone()).into(),
1314 );
1315 }
1316 }
1317 Ok(bindings)
1318 }
1319
1320 fn retain_geometry_nodes(
1321 &self,
1322 retained: &crate::RetainedUiTree,
1323 ) -> Result<(), LifecycleError> {
1324 let nodes = retained.nodes().map(crate::RetainedNode::id).collect();
1325 let runtime = self
1326 .runtime
1327 .try_borrow()
1328 .map_err(|_| LifecycleError::Borrowed)?;
1329 runtime
1330 .geometry_for(self.presentation_scope())
1331 .retain_nodes(&nodes);
1332 runtime
1333 .pointer_capture_for(self.presentation_scope())
1334 .retain_nodes(&nodes);
1335 Ok(())
1336 }
1337
1338 fn presentation_scope(&self) -> Option<&str> {
1339 self.view.as_deref().or(self.window.as_deref())
1340 }
1341
1342 fn begin_runtime_transaction(&self) -> Result<crate::UiStateSnapshot, LifecycleError> {
1343 self.runtime
1344 .try_borrow_mut()
1345 .map_err(|_| LifecycleError::Borrowed)?
1346 .begin_transaction()
1347 .map_err(LifecycleError::from)
1348 }
1349
1350 fn commit_runtime_transaction(&self) -> Result<(), LifecycleError> {
1351 self.runtime
1352 .try_borrow_mut()
1353 .map_err(|_| LifecycleError::Borrowed)?
1354 .commit_transaction()?;
1355 Ok(())
1356 }
1357
1358 #[must_use]
1359 pub fn window_id(&self) -> Option<&str> {
1360 self.window.as_deref()
1361 }
1362
1363 #[must_use]
1364 pub fn view_id(&self) -> Option<&str> {
1365 self.view.as_deref()
1366 }
1367
1368 #[must_use]
1369 pub fn root_path(&self) -> &ComponentInstancePath {
1370 &self.root_path
1371 }
1372
1373 #[must_use]
1374 pub fn compiled(&self) -> CompiledUi {
1375 self.compiled.clone()
1376 }
1377
1378 fn animation_root_path(&self) -> String {
1379 match (self.window.as_deref(), self.view.as_deref()) {
1380 (Some(window), Some(view)) => format!("window:{window}/view:{view}/root"),
1381 (Some(window), None) => format!("window:{window}/root"),
1382 (None, Some(view)) => format!("view:{view}/root"),
1383 (None, None) => "root".to_owned(),
1384 }
1385 }
1386
1387 fn trace_reconcile(&self, operation: &str, report: &crate::ReconcileReport) {
1388 const ID_SAMPLE_LIMIT: usize = 16;
1389
1390 fn ids(values: &[crate::NodeId]) -> crate::UiValue {
1391 crate::UiValue::Array(
1392 values
1393 .iter()
1394 .take(ID_SAMPLE_LIMIT)
1395 .map(|id| crate::UiValue::Integer(i64::try_from(id.get()).unwrap_or(i64::MAX)))
1396 .collect(),
1397 )
1398 }
1399
1400 let metrics = report.metrics();
1401 let truncated = [
1402 &report.mounted,
1403 &report.preserved,
1404 &report.moved,
1405 &report.unmounted,
1406 ]
1407 .into_iter()
1408 .any(|nodes| nodes.len() > ID_SAMPLE_LIMIT);
1409 let payload = crate::UiValue::Map(BTreeMap::from([
1410 ("mounted".to_owned(), ids(&report.mounted)),
1411 ("preserved".to_owned(), ids(&report.preserved)),
1412 ("moved".to_owned(), ids(&report.moved)),
1413 ("unmounted".to_owned(), ids(&report.unmounted)),
1414 ("truncated".to_owned(), crate::UiValue::Bool(truncated)),
1415 ]));
1416 if let Ok(mut runtime) = self.runtime.try_borrow_mut() {
1417 runtime.traces.push(
1418 crate::RuntimeTraceKind::Reconcile,
1419 self.root_path.to_string(),
1420 format!(
1421 "{operation}: mounted={} preserved={} moved={} unmounted={}",
1422 metrics.mounted, metrics.preserved, metrics.moved, metrics.unmounted
1423 ),
1424 Some(payload),
1425 false,
1426 );
1427 }
1428 }
1429
1430 fn context_for(&self, phase: ExecutionPhase, generation: ScriptGeneration) -> UiContext {
1431 UiContext::new(
1432 Rc::clone(&self.runtime),
1433 self.root_path.clone(),
1434 self.window.clone(),
1435 phase,
1436 self.events.clone(),
1437 )
1438 .with_optional_view_id(self.view.clone())
1439 .with_generation(generation)
1440 }
1441
1442 fn require_state(&self, required: LifecycleState) -> Result<(), LifecycleError> {
1443 if self.state == required {
1444 Ok(())
1445 } else {
1446 Err(LifecycleError::InvalidTransition {
1447 from: self.state,
1448 operation: "initialize",
1449 })
1450 }
1451 }
1452}
1453
1454#[derive(Debug, Error)]
1455pub enum LifecycleError {
1456 #[error("cannot {operation} while lifecycle is {from:?}")]
1457 InvalidTransition {
1458 from: LifecycleState,
1459 operation: &'static str,
1460 },
1461 #[error("UI runtime state is already borrowed")]
1462 Borrowed,
1463 #[error(transparent)]
1464 State(#[from] StateError),
1465 #[error(transparent)]
1466 Runtime(#[from] RuntimeError),
1467 #[error(transparent)]
1468 Animation(#[from] AnimationError),
1469 #[error(transparent)]
1470 Reconcile(#[from] crate::ReconcileError),
1471 #[error("script lifecycle has no accepted root")]
1472 MissingRoot,
1473 #[error("suspended lifecycle is missing its monotonic suspension timestamp")]
1474 MissingSuspendTime,
1475 #[error("component subtree `{0}` is missing from the accepted UiNode snapshot")]
1476 MissingComponentSubtree(ComponentInstancePath),
1477 #[error("virtual collection `{0:?}` is missing from the accepted UiNode snapshot")]
1478 MissingVirtualCollection(crate::VirtualCollectionId),
1479 #[error("effect transition exceeded the 64-callback budget with {0} callbacks")]
1480 EffectBudget(usize),
1481 #[error("effect callback `{name}` belongs to unavailable generation {generation}")]
1482 StaleEffect {
1483 name: String,
1484 generation: ScriptGeneration,
1485 },
1486 #[error(transparent)]
1487 Asset(#[from] crate::AssetError),
1488 #[error(transparent)]
1489 Transaction(#[from] crate::UiTransactionError),
1490 #[error(transparent)]
1491 Signal(#[from] crate::SignalError),
1492 #[error(transparent)]
1493 ElementRef(#[from] crate::ElementRefError),
1494 #[error(transparent)]
1495 Budget(#[from] crate::RuntimeBudgetError),
1496}
1497
1498fn topmost_paths(paths: &BTreeSet<ComponentInstancePath>) -> Vec<ComponentInstancePath> {
1499 paths
1500 .iter()
1501 .filter(|path| {
1502 !paths
1503 .iter()
1504 .any(|candidate| *path != candidate && path.is_within(candidate))
1505 })
1506 .cloned()
1507 .collect()
1508}
1509
1510fn replaceable_component_paths(
1511 root: &UiNode,
1512 root_path: &ComponentInstancePath,
1513 paths: Vec<ComponentInstancePath>,
1514 active: &BTreeSet<ComponentInstancePath>,
1515) -> Option<Vec<ComponentInstancePath>> {
1516 let index = crate::node::ComponentSubtreeIndex::new(root);
1517 let mut replaceable = BTreeSet::new();
1518 for path in paths {
1519 let mut candidate = path;
1520 loop {
1521 if active.contains(&candidate) && index.get(root, &candidate).is_some() {
1522 replaceable.insert(candidate);
1523 break;
1524 }
1525 candidate = candidate.parent()?;
1526 if &candidate == root_path {
1527 return None;
1528 }
1529 }
1530 }
1531 Some(topmost_paths(&replaceable))
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537 use crate::{
1538 AssetData, AsyncCapabilityHandler, CapabilityDescriptor, CapabilityId, CapabilityMethod,
1539 ExecutionOperation, InMemoryAssetProvider, OpaqueHandle, StateField,
1540 SubscriptionCapabilityHandler, SubscriptionWork, TaskWork, UiValue, ValueSchema,
1541 };
1542 use semver::{Version, VersionReq};
1543 use std::time::{Duration, Instant};
1544
1545 fn state_schema() -> ComponentStateSchema {
1546 ComponentStateSchema::new(BTreeMap::from([(
1547 "phase".to_owned(),
1548 StateField::new(ValueSchema::string(), UiValue::String("created".to_owned())),
1549 )]))
1550 .unwrap()
1551 }
1552
1553 const STREAM_COMPONENT_APP: &str = r#"
1554 define_component(#{
1555 metadata: #{ id: "test/stream", "export": "StreamProbe", version: "0.1.0",
1556 runtime_api: #{ min_inclusive: 1, max_exclusive: 2 },
1557 dependencies: [], capabilities: #{ "app.stream": "*" } },
1558 schema: #{ props: #{ key: #{ schema: #{ type: "string" }, required: true, sensitive: false } },
1559 state: #{ fields: #{ phase: #{ schema: #{ type: "string" },
1560 "default": #{ type: "string", value: "idle" } } } },
1561 events: #{}, slots: #{}, parts: [], effects: ["watch"] },
1562 render: Fn("render_StreamProbe"),
1563 });
1564 fn start_stream(ctx, deps) {
1565 ctx.start_subscription(
1566 "app.stream", "watch", (),
1567 Fn("received"), Fn("failed"), #{ delivery: "all" }
1568 );
1569 }
1570 fn stop_stream(ctx, deps) { () }
1571 fn received(ctx, value) { ctx.set_state("phase", value); }
1572 fn failed(ctx, error) { ctx.set_state("phase", "failed"); }
1573 fn render_StreamProbe(ctx, props) {
1574 effect("watch", (), Fn("start_stream"), Fn("stop_stream"));
1575 text(ctx.get_state("phase"))
1576 }
1577 fn view(ctx) { render_component("test/stream", #{ key: "stream" }) }
1578 "#;
1579
1580 #[test]
1581 fn lifecycle_order_and_optional_functions_are_enforced() {
1582 let mut engine = RuntimeEngine::new();
1583 let compiled = engine
1584 .compile(
1585 r#"
1586 fn init(ctx) { ctx.set_state("phase", "initialized"); }
1587 fn view(ctx) { text(ctx.get_state("phase")) }
1588 fn dispose(ctx) { ctx.set_state("phase", "disposed"); }
1589 "#,
1590 )
1591 .unwrap();
1592 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
1593 let path = ComponentInstancePath::root("App", "root");
1594 let mut lifecycle = ScriptLifecycle::new(
1595 compiled,
1596 Rc::clone(&runtime),
1597 path.clone(),
1598 Some("main".to_owned()),
1599 BTreeMap::new(),
1600 &state_schema(),
1601 )
1602 .unwrap();
1603
1604 assert!(matches!(
1605 lifecycle.render(&mut engine),
1606 Err(LifecycleError::InvalidTransition { .. })
1607 ));
1608 let root = lifecycle.start(&mut engine).unwrap();
1609 assert!(matches!(
1610 root.kind(),
1611 crate::UiNodeKind::Text { text } if text == "initialized"
1612 ));
1613 assert!(root.source().is_some());
1614 let reconcile = runtime
1615 .borrow()
1616 .traces
1617 .snapshot()
1618 .into_iter()
1619 .find(|trace| trace.kind == crate::RuntimeTraceKind::Reconcile)
1620 .expect("successful initial render must trace retained mutations");
1621 assert!(reconcile.message.contains("full: mounted=1"));
1622 assert!(matches!(reconcile.payload, Some(UiValue::Map(_))));
1623 lifecycle.dispose(&mut engine).unwrap();
1624 assert_eq!(lifecycle.state(), LifecycleState::Disposed);
1625 assert_eq!(
1626 runtime.borrow().component_state.get(&path, "phase"),
1627 Some(&UiValue::String("disposed".to_owned()))
1628 );
1629 assert!(matches!(
1630 lifecycle.dispose(&mut engine),
1631 Err(LifecycleError::InvalidTransition { .. })
1632 ));
1633 }
1634
1635 #[test]
1636 fn lifecycle_animation_uses_the_host_runtime_clock() {
1637 let mut engine = RuntimeEngine::new();
1638 let compiled = engine
1639 .compile(
1640 r#"
1641 fn view(ctx) {
1642 text("clocked")
1643 .with_key("probe")
1644 .animate(transition("width", 0.0, 100.0, 100, "linear"))
1645 }
1646 "#,
1647 )
1648 .unwrap();
1649 let start = Instant::now();
1650 let manual = crate::ManualRuntimeClock::new(start);
1651 let mut runtime_state = UiRuntimeState::new();
1652 runtime_state.clock = manual.clock();
1653 let runtime = Rc::new(RefCell::new(runtime_state));
1654 let mut lifecycle = ScriptLifecycle::new(
1655 compiled,
1656 Rc::clone(&runtime),
1657 ComponentInstancePath::root("App", "root"),
1658 Some("main".to_owned()),
1659 BTreeMap::new(),
1660 &ComponentStateSchema::default(),
1661 )
1662 .unwrap();
1663
1664 lifecycle.start(&mut engine).unwrap();
1665 assert_eq!(
1666 runtime
1667 .borrow()
1668 .animation_values
1669 .values()
1670 .copied()
1671 .collect::<Vec<_>>(),
1672 vec![0.0]
1673 );
1674
1675 manual.advance(Duration::from_millis(50));
1676 let mut runtime = runtime.borrow_mut();
1677 let now = runtime.clock.now();
1678 let frame = runtime.animations.tick(now);
1679 assert_eq!(
1680 frame.values.values().copied().collect::<Vec<_>>(),
1681 vec![50.0]
1682 );
1683 }
1684
1685 #[test]
1686 fn suspend_resume_preserves_state_and_freezes_animation_time() {
1687 let mut engine = RuntimeEngine::new();
1688 let compiled = engine
1689 .compile(
1690 r#"
1691 fn suspend(ctx) { ctx.set_state("phase", "suspended"); }
1692 fn resume(ctx, elapsed_ms) {
1693 ctx.set_state("phase", "resumed");
1694 ctx.set_state("elapsed", elapsed_ms);
1695 }
1696 fn view(ctx) {
1697 text(ctx.get_state("phase")).with_key("probe")
1698 .animate(transition("width", 0.0, 100.0, 100, "linear"))
1699 }
1700 "#,
1701 )
1702 .unwrap();
1703 let start = Instant::now();
1704 let manual = crate::ManualRuntimeClock::new(start);
1705 let mut runtime_state = UiRuntimeState::new();
1706 runtime_state.clock = manual.clock();
1707 let runtime = Rc::new(RefCell::new(runtime_state));
1708 let path = ComponentInstancePath::root("App", "root");
1709 let schema = ComponentStateSchema::new(BTreeMap::from([
1710 (
1711 "phase".to_owned(),
1712 StateField::new(ValueSchema::string(), UiValue::String("active".to_owned())),
1713 ),
1714 (
1715 "elapsed".to_owned(),
1716 StateField::new(ValueSchema::integer(), UiValue::Integer(0)),
1717 ),
1718 ]))
1719 .unwrap();
1720 let mut lifecycle = ScriptLifecycle::new(
1721 compiled,
1722 Rc::clone(&runtime),
1723 path.clone(),
1724 Some("main".to_owned()),
1725 BTreeMap::new(),
1726 &schema,
1727 )
1728 .unwrap();
1729 lifecycle.start(&mut engine).unwrap();
1730 manual.advance(Duration::from_millis(50));
1731 {
1732 let mut runtime = runtime.borrow_mut();
1733 let now = runtime.clock.now();
1734 runtime.animation_values = runtime.animations.tick(now).values;
1735 }
1736 let before = *runtime.borrow().animation_values.values().next().unwrap();
1737 assert!(lifecycle.suspend(&mut engine).unwrap());
1738 assert_eq!(lifecycle.state(), LifecycleState::Suspended);
1739 assert_eq!(
1740 runtime.borrow().component_state.get(&path, "phase"),
1741 Some(&UiValue::String("suspended".to_owned()))
1742 );
1743 assert!(!lifecycle.suspend(&mut engine).unwrap());
1744
1745 manual.advance(Duration::from_millis(1_000));
1746 assert!(lifecycle.resume(&mut engine).unwrap());
1747 assert_eq!(lifecycle.state(), LifecycleState::Running);
1748 assert_eq!(
1749 runtime.borrow().component_state.get(&path, "elapsed"),
1750 Some(&UiValue::Integer(1_000))
1751 );
1752 let after = *runtime.borrow().animation_values.values().next().unwrap();
1753 assert!(
1754 (before - after).abs() < 0.001,
1755 "animation advanced while suspended"
1756 );
1757 assert!(!lifecycle.resume(&mut engine).unwrap());
1758 }
1759
1760 #[test]
1761 fn suspended_reload_migrates_atomically_and_keeps_last_good_on_failure() {
1762 let mut engine = RuntimeEngine::new();
1763 let active = engine
1764 .compile(
1765 r#"
1766 fn suspend(ctx) { ctx.set_state("phase", "parked"); }
1767 fn view(ctx) { text(ctx.get_state("phase")) }
1768 "#,
1769 )
1770 .unwrap();
1771 let active_generation = active.generation();
1772 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
1773 let path = ComponentInstancePath::root("App", "root");
1774 let mut lifecycle = ScriptLifecycle::new(
1775 active,
1776 Rc::clone(&runtime),
1777 path.clone(),
1778 Some("main".to_owned()),
1779 BTreeMap::new(),
1780 &state_schema(),
1781 )
1782 .unwrap();
1783 lifecycle.start(&mut engine).unwrap();
1784 lifecycle.suspend(&mut engine).unwrap();
1785
1786 let rejected = engine
1787 .compile(
1788 r#"
1789 fn init(ctx) { ctx.set_state("phase", "candidate-init"); }
1790 fn resume(ctx, elapsed_ms) { throw "candidate resume failed"; }
1791 fn view(ctx) { text(ctx.get_state("phase")) }
1792 "#,
1793 )
1794 .unwrap();
1795 assert!(
1796 lifecycle
1797 .resume_reload(&mut engine, rejected, &state_schema())
1798 .is_err()
1799 );
1800 assert_eq!(lifecycle.state(), LifecycleState::Suspended);
1801 assert_eq!(lifecycle.generation(), active_generation);
1802 assert_eq!(
1803 runtime.borrow().component_state.get(&path, "phase"),
1804 Some(&UiValue::String("parked".to_owned()))
1805 );
1806
1807 let accepted = engine
1808 .compile(
1809 r#"
1810 fn init(ctx) { ctx.set_state("phase", "candidate-init"); }
1811 fn resume(ctx, elapsed_ms) { ctx.set_state("phase", "candidate-resumed"); }
1812 fn view(ctx) { text(ctx.get_state("phase")) }
1813 "#,
1814 )
1815 .unwrap();
1816 let accepted_generation = accepted.generation();
1817 lifecycle
1818 .resume_reload(&mut engine, accepted, &state_schema())
1819 .unwrap();
1820 assert_eq!(lifecycle.state(), LifecycleState::Running);
1821 assert_eq!(lifecycle.generation(), accepted_generation);
1822 assert_eq!(
1823 runtime.borrow().component_state.get(&path, "phase"),
1824 Some(&UiValue::String("candidate-resumed".to_owned()))
1825 );
1826 }
1827
1828 #[test]
1829 #[allow(clippy::too_many_lines)]
1830 fn virtual_realization_commits_the_complete_target_window_and_prunes_old_items() {
1831 let mut engine = RuntimeEngine::new();
1832 let compiled = engine
1833 .compile(
1834 r#"
1835 fn render_item(ctx, payload) {
1836 text(payload.item.label).with_key(payload.key)
1837 }
1838 fn view(ctx) {
1839 let data = [];
1840 for index in 0..32 {
1841 data.push(#{ key: `row-${index}`, label: `Row ${index}` });
1842 }
1843 virtual_collection(#{
1844 key: "rows", label: "Rows", data: data,
1845 estimated_height: 24, height: 96,
1846 overdraw_pixels: 24, alignment: "top", follow_tail: false
1847 }, Fn("render_item"))
1848 }
1849 "#,
1850 )
1851 .unwrap();
1852 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
1853 let path = ComponentInstancePath::root("App", "root");
1854 let mut lifecycle = ScriptLifecycle::new(
1855 compiled,
1856 Rc::clone(&runtime),
1857 path.clone(),
1858 Some("main".to_owned()),
1859 BTreeMap::new(),
1860 &ComponentStateSchema::default(),
1861 )
1862 .unwrap();
1863 lifecycle.start(&mut engine).unwrap();
1864 let id = engine
1865 .virtual_collection_ids_in_scope(&path)
1866 .into_iter()
1867 .next()
1868 .unwrap();
1869 let initial_indices = lifecycle
1870 .root()
1871 .unwrap()
1872 .virtual_collection_items(&id)
1873 .unwrap()
1874 .keys()
1875 .copied()
1876 .collect::<Vec<_>>();
1877 let retained = *initial_indices.last().unwrap();
1878 let first_new = retained + 1;
1879 let second_new = first_new + 1;
1880 let _ = engine.take_timings();
1881
1882 runtime
1883 .borrow()
1884 .virtual_requests
1885 .request(id.clone(), [retained, first_new]);
1886 assert!(lifecycle.realize_virtual_requests(&mut engine).unwrap());
1887 assert_eq!(
1888 lifecycle
1889 .root()
1890 .unwrap()
1891 .virtual_collection_items(&id)
1892 .unwrap()
1893 .keys()
1894 .copied()
1895 .collect::<Vec<_>>(),
1896 vec![retained, first_new]
1897 );
1898 let timings = engine.take_timings();
1899 assert_eq!(
1900 timings
1901 .iter()
1902 .filter(|timing| matches!(
1903 timing.operation,
1904 ExecutionOperation::VirtualCollection(_)
1905 ))
1906 .count(),
1907 1
1908 );
1909 assert!(timings.iter().all(|timing| timing.operations > 0));
1910
1911 runtime
1912 .borrow()
1913 .virtual_requests
1914 .request(id.clone(), [retained, first_new]);
1915 assert!(!lifecycle.realize_virtual_requests(&mut engine).unwrap());
1916 assert!(engine.take_timings().is_empty());
1917
1918 runtime
1919 .borrow()
1920 .virtual_requests
1921 .request(id.clone(), [first_new, second_new]);
1922 assert!(lifecycle.realize_virtual_requests(&mut engine).unwrap());
1923 assert_eq!(
1924 lifecycle
1925 .root()
1926 .unwrap()
1927 .virtual_collection_items(&id)
1928 .unwrap()
1929 .keys()
1930 .copied()
1931 .collect::<Vec<_>>(),
1932 vec![first_new, second_new]
1933 );
1934 assert_eq!(
1935 engine
1936 .take_timings()
1937 .iter()
1938 .filter(|timing| matches!(
1939 timing.operation,
1940 ExecutionOperation::VirtualCollection(_)
1941 ))
1942 .count(),
1943 1
1944 );
1945 }
1946
1947 #[test]
1948 fn controlled_rerender_synchronously_rebuilds_the_retained_virtual_window() {
1949 let mut engine = RuntimeEngine::new();
1950 let compiled = engine
1951 .compile(
1952 r#"
1953 fn render_item(ctx, payload) {
1954 text(`${payload.item.label}:${payload.item.active}`).with_key(payload.key)
1955 }
1956 fn view(ctx) {
1957 let active = ctx.get_state("active");
1958 let data = [];
1959 for index in 0..32 {
1960 data.push(#{
1961 key: `row-${index}`,
1962 label: `Row ${index}`,
1963 active: index == active,
1964 });
1965 }
1966 virtual_collection(#{
1967 key: "rows", label: "Rows", data: data,
1968 estimated_height: 24, height: 96,
1969 overdraw_pixels: 24, alignment: "top", follow_tail: false,
1970 reveal_key: `row-${active}`,
1971 }, Fn("render_item"))
1972 }
1973 "#,
1974 )
1975 .unwrap();
1976 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
1977 let path = ComponentInstancePath::root("App", "root");
1978 let schema = ComponentStateSchema::new(BTreeMap::from([(
1979 "active".to_owned(),
1980 StateField::new(ValueSchema::integer(), UiValue::Integer(0)),
1981 )]))
1982 .unwrap();
1983 let mut lifecycle = ScriptLifecycle::new(
1984 compiled,
1985 Rc::clone(&runtime),
1986 path.clone(),
1987 Some("main".to_owned()),
1988 BTreeMap::new(),
1989 &schema,
1990 )
1991 .unwrap();
1992 lifecycle.start(&mut engine).unwrap();
1993 let id = engine
1994 .virtual_collection_ids_in_scope(&path)
1995 .into_iter()
1996 .next()
1997 .unwrap();
1998
1999 runtime
2000 .borrow()
2001 .virtual_requests
2002 .request(id.clone(), 14..20);
2003 assert!(lifecycle.realize_virtual_requests(&mut engine).unwrap());
2004 assert_eq!(
2005 lifecycle
2006 .root()
2007 .unwrap()
2008 .virtual_collection_items(&id)
2009 .unwrap()
2010 .keys()
2011 .copied()
2012 .collect::<Vec<_>>(),
2013 (14..20).collect::<Vec<_>>()
2014 );
2015
2016 runtime
2017 .borrow_mut()
2018 .set_component_state_from_host(&path, "active", UiValue::Integer(16))
2019 .unwrap();
2020 assert!(lifecycle.render_dirty(&mut engine).unwrap());
2021 let realized = lifecycle
2022 .root()
2023 .unwrap()
2024 .virtual_collection_items(&id)
2025 .unwrap();
2026 for index in 14..20 {
2027 assert!(
2028 realized.contains_key(&index),
2029 "retained row {index} is missing"
2030 );
2031 }
2032 assert!(matches!(
2033 realized[&16].kind(),
2034 crate::UiNodeKind::Text { text } if text == "Row 16:true"
2035 ));
2036 assert!(matches!(
2037 realized[&15].kind(),
2038 crate::UiNodeKind::Text { text } if text == "Row 15:false"
2039 ));
2040 }
2041
2042 #[test]
2043 fn failed_event_callback_rolls_back_ui_state() {
2044 let mut engine = RuntimeEngine::new();
2045 let compiled = engine
2046 .compile(
2047 r#"
2048 fn view(ctx) { text(ctx.get_state("phase")) }
2049 fn fail(ctx, payload) {
2050 ctx.set_state("phase", "partial");
2051 ctx.register_action("test.temporary", Fn("fail"));
2052 ctx.open_window("temporary", "Temporary", 400, 300, false);
2053 throw "event failed";
2054 }
2055 "#,
2056 )
2057 .unwrap();
2058 let callback = engine.callback(&compiled, "fail").unwrap();
2059 let mut runtime_state = UiRuntimeState::new();
2060 runtime_state.windows.register_open("main").unwrap();
2061 let runtime = Rc::new(RefCell::new(runtime_state));
2062 let path = ComponentInstancePath::root("App", "root");
2063 let mut lifecycle = ScriptLifecycle::new(
2064 compiled,
2065 Rc::clone(&runtime),
2066 path.clone(),
2067 Some("main".to_owned()),
2068 BTreeMap::new(),
2069 &state_schema(),
2070 )
2071 .unwrap();
2072 lifecycle.start(&mut engine).unwrap();
2073 assert!(
2074 lifecycle
2075 .invoke_callback_transactional(&engine, &callback, UiValue::Null)
2076 .is_err()
2077 );
2078 assert_eq!(
2079 runtime.borrow().component_state.get(&path, "phase"),
2080 Some(&UiValue::String("created".to_owned()))
2081 );
2082 let runtime = runtime.borrow();
2083 assert!(!runtime.windows.contains("temporary"));
2084 assert!(
2085 runtime
2086 .actions
2087 .dispatch(
2088 &crate::ActionId::parse("test.temporary").unwrap(),
2089 UiValue::Null,
2090 )
2091 .is_err()
2092 );
2093 }
2094
2095 #[test]
2096 fn missing_optional_lifecycle_functions_are_valid() {
2097 let mut engine = RuntimeEngine::new();
2098 let compiled = engine
2099 .compile("fn view(ctx) { text(\"only view\") }")
2100 .unwrap();
2101 let mut lifecycle = ScriptLifecycle::new(
2102 compiled,
2103 Rc::new(RefCell::new(UiRuntimeState::new())),
2104 ComponentInstancePath::root("App", "root"),
2105 None,
2106 BTreeMap::new(),
2107 &ComponentStateSchema::default(),
2108 )
2109 .unwrap();
2110 lifecycle.start(&mut engine).unwrap();
2111 lifecycle.dispose(&mut engine).unwrap();
2112 }
2113
2114 #[test]
2115 fn retained_candidate_over_host_budget_is_rejected_before_commit() {
2116 let mut engine = RuntimeEngine::new();
2117 let compiled = engine
2118 .compile("fn view(ctx) { box([text(\"child\")]) }")
2119 .unwrap();
2120 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2121 runtime.borrow_mut().budgets.retained_nodes = 1;
2122 let mut lifecycle = ScriptLifecycle::new(
2123 compiled,
2124 Rc::clone(&runtime),
2125 ComponentInstancePath::root("App", "root"),
2126 Some("main".to_owned()),
2127 BTreeMap::new(),
2128 &ComponentStateSchema::default(),
2129 )
2130 .unwrap();
2131 assert!(matches!(
2132 lifecycle.start(&mut engine),
2133 Err(LifecycleError::Budget(
2134 crate::RuntimeBudgetError::Exceeded {
2135 resource: "retained_nodes",
2136 actual: 2,
2137 limit: 1,
2138 }
2139 ))
2140 ));
2141 assert!(lifecycle.retained().is_empty());
2142 }
2143
2144 #[test]
2145 fn layer_budget_rejects_candidate_before_commit() {
2146 let mut engine = RuntimeEngine::new();
2147 let compiled = engine
2148 .compile(
2149 r#"
2150 fn view(ctx) {
2151 box([
2152 layer(text("first"), #{ id: "first", placement: "top_right" }),
2153 layer(text("second"), #{ id: "second", placement: "bottom_right" })
2154 ])
2155 }
2156 "#,
2157 )
2158 .unwrap();
2159 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2160 runtime.borrow_mut().budgets.layers = 1;
2161 let mut lifecycle = ScriptLifecycle::new(
2162 compiled,
2163 Rc::clone(&runtime),
2164 ComponentInstancePath::root("App", "root"),
2165 Some("main".to_owned()),
2166 BTreeMap::new(),
2167 &ComponentStateSchema::default(),
2168 )
2169 .unwrap();
2170
2171 assert!(matches!(
2172 lifecycle.start(&mut engine),
2173 Err(LifecycleError::Budget(
2174 crate::RuntimeBudgetError::Exceeded {
2175 resource: "layers",
2176 actual: 2,
2177 limit: 1,
2178 }
2179 ))
2180 ));
2181 assert!(lifecycle.retained().is_empty());
2182 }
2183
2184 #[test]
2185 fn canvas_scene_budget_is_independent_from_command_budget() {
2186 let mut engine = RuntimeEngine::new();
2187 let compiled = engine
2188 .compile(
2189 r#"
2190 fn view(ctx) {
2191 box([
2192 canvas(canvas_scene([canvas_rect("a", 0.0, 0.0, 1.0, 1.0, rgb(0))])),
2193 canvas(canvas_scene([canvas_rect("b", 0.0, 0.0, 1.0, 1.0, rgb(0))]))
2194 ])
2195 }
2196 "#,
2197 )
2198 .unwrap();
2199 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2200 {
2201 let mut runtime = runtime.borrow_mut();
2202 runtime.budgets.canvas_scenes = 1;
2203 runtime.budgets.canvas_commands = 10;
2204 }
2205 let mut lifecycle = ScriptLifecycle::new(
2206 compiled,
2207 Rc::clone(&runtime),
2208 ComponentInstancePath::root("App", "root"),
2209 Some("main".to_owned()),
2210 BTreeMap::new(),
2211 &ComponentStateSchema::default(),
2212 )
2213 .unwrap();
2214
2215 assert!(matches!(
2216 lifecycle.start(&mut engine),
2217 Err(LifecycleError::Budget(
2218 crate::RuntimeBudgetError::Exceeded {
2219 resource: "canvas_scenes",
2220 actual: 2,
2221 limit: 1,
2222 }
2223 ))
2224 ));
2225 assert!(lifecycle.retained().is_empty());
2226 }
2227
2228 #[test]
2229 fn hot_reload_rolls_back_candidate_init_state_on_render_failure() {
2230 let mut engine = RuntimeEngine::new();
2231 let active = engine
2232 .compile(
2233 r#"
2234 fn init(ctx) { ctx.set_state("phase", "active"); }
2235 fn view(ctx) { text(ctx.get_state("phase")) }
2236 "#,
2237 )
2238 .unwrap();
2239 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2240 let path = ComponentInstancePath::root("App", "root");
2241 let mut lifecycle = ScriptLifecycle::new(
2242 active,
2243 Rc::clone(&runtime),
2244 path.clone(),
2245 None,
2246 BTreeMap::new(),
2247 &state_schema(),
2248 )
2249 .unwrap();
2250 lifecycle.start(&mut engine).unwrap();
2251 let active_generation = lifecycle.generation();
2252
2253 let rejected = engine
2254 .compile(
2255 r#"
2256 fn init(ctx) { ctx.set_state("phase", "candidate"); }
2257 fn view(ctx) { throw "reject"; }
2258 "#,
2259 )
2260 .unwrap();
2261 assert!(
2262 lifecycle
2263 .reload(&mut engine, rejected, &state_schema())
2264 .is_err()
2265 );
2266 assert_eq!(lifecycle.generation(), active_generation);
2267 assert_eq!(
2268 runtime.borrow().component_state.get(&path, "phase"),
2269 Some(&UiValue::String("active".to_owned()))
2270 );
2271 }
2272
2273 #[test]
2274 fn hot_reload_rolls_back_engine_generation_after_retained_validation_failure() {
2275 let mut engine = RuntimeEngine::new();
2276 let active = engine
2277 .compile(
2278 r#"
2279 fn init(ctx) { ctx.set_state("phase", "active"); }
2280 fn view(ctx) { text(ctx.get_state("phase")) }
2281 "#,
2282 )
2283 .unwrap();
2284 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2285 let path = ComponentInstancePath::root("App", "root");
2286 let mut lifecycle = ScriptLifecycle::new(
2287 active,
2288 Rc::clone(&runtime),
2289 path.clone(),
2290 None,
2291 BTreeMap::new(),
2292 &state_schema(),
2293 )
2294 .unwrap();
2295 lifecycle.start(&mut engine).unwrap();
2296 let active_generation = lifecycle.generation();
2297 let reconcile_trace_count = runtime
2298 .borrow()
2299 .traces
2300 .snapshot()
2301 .iter()
2302 .filter(|trace| trace.kind == crate::RuntimeTraceKind::Reconcile)
2303 .count();
2304
2305 let rejected = engine
2306 .compile(
2307 r#"
2308 fn init(ctx) { ctx.set_state("phase", "candidate"); }
2309 fn view(ctx) {
2310 row([
2311 text("first").with_key("duplicate"),
2312 text("second").with_key("duplicate")
2313 ])
2314 }
2315 "#,
2316 )
2317 .unwrap();
2318 assert!(
2319 lifecycle
2320 .reload(&mut engine, rejected, &state_schema())
2321 .is_err()
2322 );
2323 assert_eq!(lifecycle.generation(), active_generation);
2324 assert!(engine.is_current(active_generation));
2325 assert_eq!(
2326 runtime.borrow().component_state.get(&path, "phase"),
2327 Some(&UiValue::String("active".to_owned()))
2328 );
2329 assert!(matches!(
2330 lifecycle.root().unwrap().kind(),
2331 crate::UiNodeKind::Text { text } if text == "active"
2332 ));
2333 assert_eq!(
2334 runtime
2335 .borrow()
2336 .traces
2337 .snapshot()
2338 .iter()
2339 .filter(|trace| trace.kind == crate::RuntimeTraceKind::Reconcile)
2340 .count(),
2341 reconcile_trace_count
2342 );
2343 }
2344
2345 #[test]
2346 fn async_capability_completes_through_foreground_delivery() {
2347 struct Echo;
2348 impl AsyncCapabilityHandler for Echo {
2349 fn start(&mut self, _: &str, input: UiValue) -> Result<TaskWork, String> {
2350 Ok(TaskWork::new(move || Ok(input)))
2351 }
2352 }
2353
2354 let mut engine = RuntimeEngine::new();
2355 let compiled = engine
2356 .compile(
2357 r#"
2358 fn init(ctx) {
2359 ctx.start_task(
2360 "app.echo", "echo", "loaded",
2361 Fn("loaded"), Fn("failed")
2362 );
2363 }
2364 fn loaded(ctx, value) { ctx.set_state("phase", value); }
2365 fn failed(ctx, error) { ctx.set_state("phase", "failed"); }
2366 fn view(ctx) { text(ctx.get_state("phase")) }
2367 "#,
2368 )
2369 .unwrap();
2370 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2371 let capability = CapabilityId::parse("app.echo").unwrap();
2372 runtime
2373 .borrow_mut()
2374 .capabilities
2375 .register_async(
2376 CapabilityDescriptor {
2377 id: capability.clone(),
2378 version: Version::new(1, 0, 0),
2379 methods: BTreeMap::from([(
2380 "echo".to_owned(),
2381 CapabilityMethod {
2382 input: ValueSchema::string(),
2383 output: ValueSchema::string(),
2384 },
2385 )]),
2386 },
2387 Echo,
2388 )
2389 .unwrap();
2390 runtime
2391 .borrow_mut()
2392 .capabilities
2393 .activate(&BTreeMap::from([(capability, VersionReq::STAR)]))
2394 .unwrap();
2395 let path = ComponentInstancePath::root("App", "root");
2396 let mut lifecycle = ScriptLifecycle::new(
2397 compiled,
2398 Rc::clone(&runtime),
2399 path.clone(),
2400 None,
2401 BTreeMap::new(),
2402 &state_schema(),
2403 )
2404 .unwrap();
2405 lifecycle.start(&mut engine).unwrap();
2406
2407 let deadline = Instant::now() + Duration::from_secs(1);
2408 loop {
2409 let deliveries = runtime.borrow_mut().tasks.drain(lifecycle.generation());
2410 if !deliveries.is_empty() {
2411 for delivery in deliveries {
2412 let _ = lifecycle.invoke_async_delivery(&engine, delivery).unwrap();
2413 }
2414 lifecycle.render(&mut engine).unwrap();
2415 break;
2416 }
2417 assert!(Instant::now() < deadline);
2418 std::thread::yield_now();
2419 }
2420 assert_eq!(
2421 runtime.borrow().component_state.get(&path, "phase"),
2422 Some(&UiValue::String("loaded".to_owned()))
2423 );
2424 }
2425
2426 #[test]
2427 fn async_image_decode_completes_through_lifecycle_delivery() {
2428 let mut png = std::io::Cursor::new(Vec::new());
2429 image::DynamicImage::new_rgba8(1, 1)
2430 .write_to(&mut png, image::ImageFormat::Png)
2431 .unwrap();
2432 let mut engine = RuntimeEngine::new();
2433 let compiled = engine
2434 .compile(
2435 r#"
2436 fn init(ctx) {
2437 ctx.start_image_decode(
2438 asset("app/pixel"), Fn("loaded"), Fn("failed")
2439 );
2440 }
2441 fn loaded(ctx, handle) { ctx.set_state("image", handle); }
2442 fn failed(ctx, error) { () }
2443 fn view(ctx) { image(ctx.get_state("image")) }
2444 "#,
2445 )
2446 .unwrap();
2447 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2448 runtime
2449 .borrow()
2450 .assets
2451 .register(
2452 "app",
2453 InMemoryAssetProvider::new(BTreeMap::from([(
2454 "pixel".to_owned(),
2455 AssetData {
2456 mime_type: "image/png".to_owned(),
2457 bytes: png.into_inner(),
2458 },
2459 )])),
2460 )
2461 .unwrap();
2462 let path = ComponentInstancePath::root("App", "root");
2463 let schema = ComponentStateSchema::new(BTreeMap::from([(
2464 "image".to_owned(),
2465 StateField::new(
2466 ValueSchema::Handle {
2467 kind: "image".to_owned(),
2468 },
2469 UiValue::Handle(OpaqueHandle::new("image", 0)),
2470 ),
2471 )]))
2472 .unwrap();
2473 let mut lifecycle = ScriptLifecycle::new(
2474 compiled,
2475 Rc::clone(&runtime),
2476 path.clone(),
2477 None,
2478 BTreeMap::new(),
2479 &schema,
2480 )
2481 .unwrap();
2482 lifecycle.start(&mut engine).unwrap();
2483
2484 let assets = runtime.borrow().assets.clone();
2485 let deadline = Instant::now() + Duration::from_secs(1);
2486 loop {
2487 let deliveries = assets.drain_image_decodes(lifecycle.generation()).unwrap();
2488 if !deliveries.is_empty() {
2489 for delivery in deliveries {
2490 let _ = lifecycle.invoke_async_delivery(&engine, delivery).unwrap();
2491 }
2492 lifecycle.render(&mut engine).unwrap();
2493 break;
2494 }
2495 assert!(Instant::now() < deadline);
2496 std::thread::yield_now();
2497 }
2498 assert!(matches!(
2499 runtime.borrow().component_state.get(&path, "image"),
2500 Some(UiValue::Handle(handle)) if handle.id() != 0
2501 ));
2502 }
2503
2504 #[test]
2505 fn subscription_capability_streams_until_close() {
2506 struct Stream;
2507 impl SubscriptionCapabilityHandler for Stream {
2508 fn subscribe(&mut self, _: &str, _: UiValue) -> Result<SubscriptionWork, String> {
2509 Ok(SubscriptionWork::new(|emitter| {
2510 emitter.emit(UiValue::String("first".to_owned())).unwrap();
2511 emitter.emit(UiValue::String("second".to_owned())).unwrap();
2512 }))
2513 }
2514 }
2515
2516 let mut engine = RuntimeEngine::new();
2517 let compiled = engine.compile(STREAM_COMPONENT_APP).unwrap();
2518 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2519 let capability = CapabilityId::parse("app.stream").unwrap();
2520 runtime
2521 .borrow_mut()
2522 .capabilities
2523 .register_subscription(
2524 CapabilityDescriptor {
2525 id: capability.clone(),
2526 version: Version::new(1, 0, 0),
2527 methods: BTreeMap::from([(
2528 "watch".to_owned(),
2529 CapabilityMethod {
2530 input: ValueSchema::Null,
2531 output: ValueSchema::string(),
2532 },
2533 )]),
2534 },
2535 Stream,
2536 )
2537 .unwrap();
2538 runtime
2539 .borrow_mut()
2540 .capabilities
2541 .activate(&BTreeMap::from([(capability, VersionReq::STAR)]))
2542 .unwrap();
2543 let path = ComponentInstancePath::root("App", "root");
2544 let mut lifecycle = ScriptLifecycle::new(
2545 compiled,
2546 Rc::clone(&runtime),
2547 path.clone(),
2548 None,
2549 BTreeMap::new(),
2550 &state_schema(),
2551 )
2552 .unwrap();
2553 lifecycle.start(&mut engine).unwrap();
2554
2555 let deadline = Instant::now() + Duration::from_secs(1);
2556 loop {
2557 let deliveries = runtime
2558 .borrow_mut()
2559 .subscriptions
2560 .drain(lifecycle.generation());
2561 for delivery in deliveries {
2562 let _ = lifecycle.invoke_async_delivery(&engine, delivery).unwrap();
2563 }
2564 if runtime.borrow().subscriptions.active_count() == 0 {
2565 lifecycle.render(&mut engine).unwrap();
2566 break;
2567 }
2568 assert!(Instant::now() < deadline);
2569 std::thread::yield_now();
2570 }
2571 assert_eq!(
2572 runtime
2573 .borrow()
2574 .component_state
2575 .get(&path.child("StreamProbe", "stream"), "phase"),
2576 Some(&UiValue::String("second".to_owned()))
2577 );
2578 }
2579
2580 #[test]
2581 fn subscription_start_outside_a_declarative_effect_is_rejected() {
2582 let mut engine = RuntimeEngine::new();
2583 let compiled = engine
2584 .compile(
2585 r#"
2586 fn init(ctx) {
2587 ctx.start_subscription(
2588 "app.stream", "watch", (),
2589 Fn("received"), Fn("failed"), #{ delivery: "all" }
2590 );
2591 }
2592 fn received(ctx, value) { () }
2593 fn failed(ctx, error) { () }
2594 fn view(ctx) { text("never mounted") }
2595 "#,
2596 )
2597 .unwrap();
2598 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
2599 let mut lifecycle = ScriptLifecycle::new(
2600 compiled,
2601 runtime,
2602 ComponentInstancePath::root("App", "root"),
2603 None,
2604 BTreeMap::new(),
2605 &state_schema(),
2606 )
2607 .unwrap();
2608 let error = lifecycle.start(&mut engine).unwrap_err().to_string();
2609 assert!(
2610 error.contains("subscriptions must be started by a declarative component effect"),
2611 "unexpected error: {error}"
2612 );
2613 }
2614}