Skip to main content

gpui_rhai/
lifecycle.rs

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