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