Skip to main content

gpui_rhai/
lifecycle.rs

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