Skip to main content

gpui_rhai/
lifecycle.rs

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