Skip to main content

gpui_rhai/
lifecycle.rs

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