Skip to main content

gpui_rhai/
lifecycle.rs

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