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