Skip to main content

cranpose_core/
composition.rs

1use std::rc::Rc;
2
3use smallvec::SmallVec;
4use web_time::Instant;
5
6use crate::{
7    Applier, ApplierGuard, ApplierHost, CommandQueue, Composer, ConcreteApplierHost, DIAGNOSTICS,
8    DefaultScheduler, Key, NodeError, NodeId, PausableContent, RecomposeScope, RetentionPolicy,
9    Runtime, RuntimeHandle, ScopeId, SlotDebugSnapshot, SlotTable, SlotTableDebugStats, SlotsHost,
10    collections::map::HashMap, debug_scope_invalidation_sources, debug_scope_label, runtime,
11    scheduler_ref,
12};
13
14/// A running composition that turns root content into nodes. `A` is the applier type that creates
15/// and edits those nodes.
16pub struct Composition<A: Applier + 'static> {
17    /// The composer of the last pass over the root slots, kept so the next
18    /// pass reuses its core. First, so it lets go of the tree before the
19    /// fields below do.
20    parked_composer: Option<Composer>,
21    pub(crate) composer_state: Rc<crate::composer::ComposerRuntimeState>,
22    pub(crate) slots: Rc<SlotsHost>,
23    pub(crate) applier: Rc<ConcreteApplierHost<A>>,
24    pub(crate) runtime: Runtime,
25    pub(crate) root: Option<NodeId>,
26    pub(crate) root_key: Option<Key>,
27    pub(crate) root_render_requested: bool,
28    pub(crate) last_pass_stats: CompositionPassDebugStats,
29    teardown: Option<runtime::StateTeardownScope>,
30}
31
32/// The budget of a frame's layout pass for pausable content, which closes
33/// when it drops: content that waited asks for the next frame.
34#[doc(hidden)]
35pub struct LayoutBudget {
36    state: Rc<crate::composer::ComposerRuntimeState>,
37    runtime: RuntimeHandle,
38}
39
40impl Drop for LayoutBudget {
41    fn drop(&mut self) {
42        self.state.pausing.close_layout(&self.runtime);
43    }
44}
45
46/// Upper bound on chained root-render replays and scope-recomposition rounds.
47///
48/// Each root render clears `root_render_requested` but may re-raise it if a
49/// recompose pass inside `render()` promotes a scope callback to the root
50/// (see `Composer::recompose_group` in recompose.rs — callbacks that cannot
51/// run invalidate their `callback_promotion_target`, and if no ancestor can
52/// absorb the callback, `request_root_render()` is called). Each promotion
53/// walks up one parent scope, so natural convergence is bounded by the
54/// composition depth. The same invariant bounds `process_invalid_scopes`:
55/// recomposing a scope may invalidate others, but the chain must terminate.
56///
57/// This constant is a safety net for reentrant-render bugs. Exceeding it
58/// trips a `debug_assert!` in dev/test builds (loud failure so regressions
59/// are caught immediately) and falls back to a break + `log::error!` in
60/// release builds so end users do not see the UI thread panic.
61pub const ROOT_RENDER_REPLAY_LIMIT: usize = 100;
62
63impl<A: Applier + 'static> Composition<A> {
64    /// Creates a composition that builds its nodes through `applier` and uses a new runtime with
65    /// the default scheduler.
66    pub fn new(applier: A) -> Self {
67        Self::with_runtime(applier, Runtime::new(scheduler_ref(DefaultScheduler)))
68    }
69
70    /// Creates a composition that builds its nodes through `applier` and uses `runtime` for state
71    /// invalidation and scheduling.
72    pub fn with_runtime(applier: A, runtime: Runtime) -> Self {
73        let composer_state = Rc::new(crate::composer::ComposerRuntimeState::default());
74        let slots = Rc::new(SlotsHost::new(SlotTable::new()));
75        let applier = Rc::new(ConcreteApplierHost::new(applier));
76        Self {
77            parked_composer: None,
78            composer_state,
79            slots,
80            applier,
81            runtime,
82            root: None,
83            root_key: None,
84            root_render_requested: false,
85            last_pass_stats: CompositionPassDebugStats::default(),
86            teardown: None,
87        }
88    }
89
90    /// Returns the root group key captured from the most recent `render()` call,
91    /// or `None` before the first render.
92    pub fn root_key(&self) -> Option<Key> {
93        self.root_key
94    }
95
96    /// Sets the budget and the eviction order for groups retained when they leave the composition.
97    /// See `RetentionMode`.
98    pub fn set_retention_policy(&self, policy: RetentionPolicy) {
99        self.composer_state.set_retention_policy(policy);
100    }
101
102    fn slots_host(&self) -> Rc<SlotsHost> {
103        Rc::clone(&self.slots)
104    }
105
106    fn applier_host(&self) -> Rc<dyn ApplierHost> {
107        Rc::<ConcreteApplierHost<A>>::clone(&self.applier)
108    }
109
110    fn reset_last_pass_stats(&mut self) {
111        self.last_pass_stats = CompositionPassDebugStats::default();
112    }
113
114    #[expect(clippy::print_stderr, reason = "COMPOSE_DEBUG_SLOT_TABLE asks for it")]
115    fn maybe_dump_slot_table(&self, label: &str) {
116        if !DIAGNOSTICS.compose_debug_slot_table.is_set() {
117            return;
118        }
119        eprintln!(
120            "[COMPOSE_DEBUG_SLOT_TABLE] {label}\n{:#?}",
121            self.debug_slot_snapshot()
122        );
123    }
124
125    /// Returns whether a root render was requested and clears the request.
126    pub fn take_root_render_request(&mut self) -> bool {
127        std::mem::take(&mut self.root_render_requested)
128    }
129
130    /// Requests another render of the root content and schedules a frame for it.
131    pub fn request_root_render(&mut self) {
132        self.root_render_requested = true;
133        self.runtime.handle().schedule();
134    }
135
136    fn record_pass_stats(
137        &mut self,
138        commands: &CommandQueue,
139        side_effects: &Vec<Box<dyn FnOnce()>>,
140    ) {
141        self.last_pass_stats.commands_len = self.last_pass_stats.commands_len.max(commands.len());
142        self.last_pass_stats.commands_cap =
143            self.last_pass_stats.commands_cap.max(commands.capacity());
144        self.last_pass_stats.command_payload_len_bytes = self
145            .last_pass_stats
146            .command_payload_len_bytes
147            .max(commands.payload_len_bytes());
148        self.last_pass_stats.command_payload_cap_bytes = self
149            .last_pass_stats
150            .command_payload_cap_bytes
151            .max(commands.payload_capacity_bytes());
152        self.last_pass_stats.sync_children_len = self
153            .last_pass_stats
154            .sync_children_len
155            .max(commands.sync_children.len());
156        self.last_pass_stats.sync_children_cap = self
157            .last_pass_stats
158            .sync_children_cap
159            .max(commands.sync_children.capacity());
160        self.last_pass_stats.sync_child_ids_len = self
161            .last_pass_stats
162            .sync_child_ids_len
163            .max(commands.sync_child_ids.len());
164        self.last_pass_stats.sync_child_ids_cap = self
165            .last_pass_stats
166            .sync_child_ids_cap
167            .max(commands.sync_child_ids.capacity());
168        self.last_pass_stats.side_effects_len = self
169            .last_pass_stats
170            .side_effects_len
171            .max(side_effects.len());
172        self.last_pass_stats.side_effects_cap = self
173            .last_pass_stats
174            .side_effects_cap
175            .max(side_effects.capacity());
176    }
177
178    fn finalize_runtime_state(&mut self) {
179        if !self.runtime.has_updates()
180            && !self.runtime.has_invalid_scopes()
181            && !self.runtime.has_frame_callbacks()
182            && !self.runtime.has_pending_ui()
183        {
184            self.runtime.set_needs_frame(false);
185        }
186    }
187
188    fn abandon_host_after_apply_failure(&mut self, host: &Rc<SlotsHost>) {
189        host.abandon_after_apply_failure();
190        if Rc::ptr_eq(host, &self.slots) {
191            self.root = None;
192        }
193        self.root_render_requested = true;
194        self.finalize_runtime_state();
195    }
196
197    /// Notes a finished pass's queue and side effects in the debug stats,
198    /// then applies its commands.
199    fn apply_pass(
200        &mut self,
201        host: &Rc<SlotsHost>,
202        runtime_handle: &RuntimeHandle,
203        composer: &Composer,
204        side_effects: &Vec<Box<dyn FnOnce()>>,
205    ) -> Result<(), NodeError> {
206        composer.with_commands(|commands| self.record_pass_stats(commands, side_effects));
207        self.apply_commands_and_updates_for_host(host, runtime_handle, composer)
208    }
209
210    /// Applies the commands `composer`'s pass queued, in its queue, which
211    /// keeps its storage for the next pass, and then the runtime's updates.
212    fn apply_commands_and_updates_for_host(
213        &mut self,
214        host: &Rc<SlotsHost>,
215        runtime_handle: &RuntimeHandle,
216        composer: &Composer,
217    ) -> Result<(), NodeError> {
218        let result = composer.with_commands(|commands| {
219            let mut applier = self.applier.borrow_dyn();
220            let mut result = commands.apply(&mut *applier);
221            if result.is_ok() {
222                for update in runtime_handle.take_updates() {
223                    if let Err(err) = update.apply(&mut *applier) {
224                        result = Err(err);
225                        break;
226                    }
227                }
228            }
229            result
230        });
231        if result.is_err() {
232            self.abandon_host_after_apply_failure(host);
233        }
234        result
235    }
236
237    fn render_root_pass(&mut self, key: Key, content: &mut dyn FnMut()) -> Result<(), NodeError> {
238        self.root_key = Some(key);
239        self.root_render_requested = false;
240        let runtime_handle = self.runtime_handle();
241        runtime_handle.drain_ui();
242        let side_effects = {
243            let _teardown = runtime::enter_state_teardown_scope();
244            let composer = Composer::new_with_shared_state(
245                Rc::clone(&self.composer_state),
246                Rc::clone(&self.slots),
247                Rc::<ConcreteApplierHost<A>>::clone(&self.applier),
248                runtime_handle.clone(),
249                self.root,
250            );
251            let (root, side_effects, compact_applier) = composer.install(|composer| {
252                let ((), outcome) = composer.try_with_slot_host_pass(
253                    Rc::clone(&self.slots),
254                    crate::slot::SlotPassMode::Compose,
255                    |composer| composer.with_group(key, |_| content()),
256                )?;
257                let root = composer.root();
258                let side_effects = composer.take_side_effects();
259                Ok((root, side_effects, outcome.compacted))
260            })?;
261            self.apply_pass(
262                &Rc::clone(&self.slots),
263                &runtime_handle,
264                &composer,
265                &side_effects,
266            )?;
267            if compact_applier {
268                self.applier.compact();
269                self.applier.borrow_dyn().clear_recycled_nodes();
270            }
271
272            self.root = root;
273            side_effects
274        };
275        runtime_handle.drain_ui();
276        for effect in side_effects {
277            effect();
278        }
279        runtime_handle.drain_ui();
280        self.maybe_dump_slot_table("root_render_pass");
281        Ok(())
282    }
283
284    fn reconcile_with_content(
285        &mut self,
286        key: Key,
287        content: &mut dyn FnMut(),
288    ) -> Result<bool, NodeError> {
289        self.root_key = Some(key);
290        let mut did_work = false;
291        let mut root_render_replays = 0usize;
292        loop {
293            did_work |= self.process_invalid_scopes_within_budget()?;
294            if !self.take_root_render_request() {
295                return Ok(did_work);
296            }
297
298            root_render_replays += 1;
299            if root_render_replays > ROOT_RENDER_REPLAY_LIMIT {
300                log::error!(
301                    "root render replay looped past {ROOT_RENDER_REPLAY_LIMIT} iterations; breaking to keep UI responsive"
302                );
303                return Err(NodeError::RecompositionLimitExceeded {
304                    operation: "root render replay",
305                    limit: ROOT_RENDER_REPLAY_LIMIT,
306                });
307            }
308
309            self.render_root_pass(key, content)?;
310            did_work = true;
311        }
312    }
313
314    /// Composes `content` as the root group identified by `key`, then recomposes the scopes that
315    /// became invalid during the pass.
316    pub fn render(&mut self, key: Key, mut content: impl FnMut()) -> Result<(), NodeError> {
317        self.reset_last_pass_stats();
318        self.render_root_pass(key, &mut content)?;
319        let _ = self.process_invalid_scopes()?;
320        Ok(())
321    }
322
323    /// Perform a root render and continue replaying any resulting root-render
324    /// requests until the composition reaches a stable fixpoint.
325    pub fn render_stable(&mut self, key: Key, mut content: impl FnMut()) -> Result<(), NodeError> {
326        self.reset_last_pass_stats();
327        self.render_root_pass(key, &mut content)?;
328        let _ = self.reconcile_with_content(key, &mut content)?;
329        Ok(())
330    }
331
332    /// Process invalid scopes and any resulting root-render requests until the
333    /// composition reaches a stable fixpoint for the supplied root content.
334    pub fn reconcile(&mut self, key: Key, mut content: impl FnMut()) -> Result<bool, NodeError> {
335        self.reconcile_with_content(key, &mut content)
336    }
337
338    /// Returns true if composition needs to process invalid scopes (recompose).
339    ///
340    /// This checks both:
341    /// - `has_updates()`: composition scopes that were invalidated by state changes
342    /// - `needs_frame()`: animation callbacks that may have pending work
343    ///
344    /// Note: For scroll performance, ensure scroll state changes use `Cell<T>` instead
345    /// of `MutableState<T>` to avoid triggering recomposition on every scroll frame.
346    pub fn should_render(&self) -> bool {
347        self.root_render_requested || self.runtime.needs_frame() || self.runtime.has_updates()
348    }
349
350    /// Whether any composition scope is actually invalid, i.e. whether running
351    /// the composable tree could produce a different result than last time.
352    ///
353    /// Deliberately excludes [`Runtime::needs_frame`], which [`Self::should_render`]
354    /// includes. An armed frame callback means the *runtime* owes someone a
355    /// tick - a future to resume, a dispatcher to drain - and every app with a
356    /// game loop or a polling effect has one armed at all times. Re-running the
357    /// composition for it recomposes a tree that nothing invalidated. Callers
358    /// deciding "should I tick" want [`Self::should_render`]; callers deciding
359    /// "should I recompose" want this.
360    ///
361    /// It is only meaningful *after* the frame callbacks have been drained: a
362    /// callback that writes state invalidates its readers as it runs, so the
363    /// answer is a question about work already discovered, not work still to
364    /// come.
365    pub fn should_recompose(&self) -> bool {
366        self.root_render_requested || self.runtime.has_updates()
367    }
368
369    /// Sets how long a frame's composition runs before composables that
370    /// pausable content calls for the first time wait for a later frame.
371    /// `None`, the default, composes such content in full in its pass. Each
372    /// frame still runs at least one body that waits.
373    pub fn set_pausable_budget(&self, budget: Option<std::time::Duration>) {
374        self.composer_state.pausing.set_budget(budget);
375    }
376
377    /// Opens the frame's budget for pausable content that a measure composes,
378    /// as `BoxWithConstraints` composes its content, until the returned
379    /// budget drops: that content composes across frames as the
380    /// composition's own does.
381    #[doc(hidden)]
382    pub fn open_layout_budget(&self) -> LayoutBudget {
383        self.composer_state.pausing.open_layout();
384        LayoutBudget {
385            state: Rc::clone(&self.composer_state),
386            runtime: self.runtime.handle(),
387        }
388    }
389
390    /// Returns a handle to the runtime this composition uses.
391    pub fn runtime_handle(&self) -> RuntimeHandle {
392        self.runtime.handle()
393    }
394
395    /// The runtime this composition runs on, to ask without a handle.
396    pub fn runtime(&self) -> &Runtime {
397        &self.runtime
398    }
399
400    /// Borrows the applier mutably. Panics if it is already borrowed.
401    pub fn applier_mut(&mut self) -> ApplierGuard<'_, A> {
402        self.applier.borrow_typed()
403    }
404
405    /// Returns the root node a pass produced, or `None` if none has yet.
406    pub fn root(&self) -> Option<NodeId> {
407        self.root
408    }
409
410    /// Returns one `(group index, call site key, scope id, subtree length)` tuple per group in the
411    /// slot table, for debugging.
412    pub fn debug_dump_slot_table_groups(&self) -> Vec<(usize, Key, Option<ScopeId>, usize)> {
413        self.slots.borrow().debug_dump_groups()
414    }
415
416    /// Returns the entries of the slot table, for debugging.
417    pub fn debug_dump_slot_entries(&self) -> Vec<crate::SlotDebugEntry> {
418        self.slots.borrow().debug_dump_slot_entries()
419    }
420
421    /// Returns the heap memory in bytes that the slot table holds.
422    pub fn slot_table_heap_bytes(&self) -> usize {
423        self.slots.borrow().heap_bytes()
424    }
425
426    /// Returns size counters of the slot table, for debugging.
427    pub fn debug_slot_table_stats(&self) -> SlotTableDebugStats {
428        self.slots.debug_stats()
429    }
430
431    /// Returns a copy of the slot table's structure, for debugging.
432    pub fn debug_slot_snapshot(&self) -> SlotDebugSnapshot {
433        self.slots.debug_snapshot()
434    }
435
436    /// Returns the buffer sizes of the last composition pass, for debugging.
437    pub fn debug_last_pass_stats(&self) -> CompositionPassDebugStats {
438        self.last_pass_stats
439    }
440
441    #[cfg(test)]
442    pub(crate) fn debug_validate_slots(&self) -> Result<(), crate::slot::SlotInvariantError> {
443        let table = self.slots.borrow();
444        table.validate()?;
445        self.composer_state
446            .validate_host_retention(self.slots.as_ref(), &table)
447    }
448
449    /// The scopes grouped by the slot host each recomposes in, in the order
450    /// each host first appears.
451    /// The slots host `scope` recomposes in.
452    fn host_for_scope(&self, scope: &RecomposeScope, root_host: &Rc<SlotsHost>) -> Rc<SlotsHost> {
453        scope
454            .slots_runtime_state()
455            .and_then(|state| {
456                scope
457                    .slots_storage_key()
458                    .and_then(|storage_key| state.host_for_storage_key(storage_key))
459            })
460            .or_else(|| {
461                scope
462                    .slots_storage_key()
463                    .and_then(|storage_key| self.composer_state.host_for_storage_key(storage_key))
464            })
465            .unwrap_or_else(|| Rc::clone(root_host))
466    }
467
468    /// The slots host of each scope, with its scopes in order.
469    fn group_scopes_by_host(
470        &self,
471        scopes: Vec<RecomposeScope>,
472    ) -> SmallVec<[(Rc<SlotsHost>, Vec<RecomposeScope>); 1]> {
473        let root_host = self.slots_host();
474        // Scopes of one slots identity share a host: the usual frame keeps
475        // its scope list as the one group.
476        let first_identity = scopes.first().map(RecomposeScope::slots_host_identity);
477        if let Some(first) = scopes.first()
478            && scopes
479                .iter()
480                .all(|scope| Some(scope.slots_host_identity()) == first_identity)
481        {
482            let host = self.host_for_scope(first, &root_host);
483            return smallvec::smallvec![(host, scopes)];
484        }
485        let mut scope_groups: SmallVec<[(Rc<SlotsHost>, Vec<RecomposeScope>); 1]> = SmallVec::new();
486        let mut scope_group_index: HashMap<usize, usize> = HashMap::default();
487        let mut previous_host: Option<(_, usize)> = None;
488        for scope in scopes {
489            let identity = scope.slots_host_identity();
490            if let Some((previous_identity, index)) = previous_host
491                && previous_identity == identity
492            {
493                scope_groups[index].1.push(scope);
494                continue;
495            }
496            let host = self.host_for_scope(&scope, &root_host);
497            let host_key = host.storage_key();
498            let index = if let Some(index) = scope_group_index.get(&host_key).copied() {
499                scope_groups[index].1.push(scope);
500                index
501            } else {
502                scope_group_index.insert(host_key, scope_groups.len());
503                scope_groups.push((host, vec![scope]));
504                scope_groups.len() - 1
505            };
506            previous_host = Some((identity, index));
507        }
508        scope_groups
509    }
510
511    // Recomposes within a frame's budget for pausable content: bodies that
512    // wait run after the invalid scopes while the budget lasts.
513    fn process_invalid_scopes_within_budget(&mut self) -> Result<bool, NodeError> {
514        self.composer_state.pausing.start_budget();
515        let result = self.process_invalid_scopes_until_root_request();
516        self.composer_state.pausing.end_budget(&self.runtime);
517        result
518    }
519
520    fn process_invalid_scopes_until_root_request(&mut self) -> Result<bool, NodeError> {
521        let runtime_handle = self.runtime_handle();
522        let mut did_recompose = false;
523        let mut loop_count = 0;
524        loop {
525            loop_count += 1;
526            if loop_count > ROOT_RENDER_REPLAY_LIMIT {
527                log::error!(
528                    "process_invalid_scopes looped past {ROOT_RENDER_REPLAY_LIMIT} iterations; breaking to keep UI responsive"
529                );
530                return Err(NodeError::RecompositionLimitExceeded {
531                    operation: "process_invalid_scopes",
532                    limit: ROOT_RENDER_REPLAY_LIMIT,
533                });
534            }
535            runtime_handle.drain_ui();
536            self.dispose_forgotten_movables()?;
537            let (scopes, batch) = match runtime_handle.take_invalidated_scopes() {
538                Some(scopes) => (scopes, Batch::Invalid),
539                None => match self.composer_state.pausing.take_resumable() {
540                    Some((content, scopes)) => (scopes, Batch::Waiting(content)),
541                    None => break,
542                },
543            };
544            let Some(scopes) = self.rerun_derivations(&runtime_handle, scopes) else {
545                continue;
546            };
547            did_recompose = true;
548            let scope_groups = self.group_scopes_by_host(scopes);
549            for (index, (host, scopes)) in scope_groups.iter().enumerate() {
550                let side_effects =
551                    self.recompose_host_scopes(host, scopes, &batch, &runtime_handle)?;
552                runtime_handle.drain_ui();
553                for effect in side_effects {
554                    effect();
555                }
556                runtime_handle.drain_ui();
557                self.maybe_dump_slot_table("recompose_pass");
558                if self.root_render_requested {
559                    requeue_scopes(&runtime_handle, &batch, &scope_groups[index + 1..]);
560                    break;
561                }
562            }
563            recycle_scope_groups(&runtime_handle, scope_groups);
564            if let Batch::Waiting(content) = batch {
565                self.composer_state.pausing.finish_resumed(content);
566            }
567            if self.root_render_requested {
568                break;
569            }
570        }
571        self.finalize_runtime_state();
572        Ok(did_recompose)
573    }
574
575    // Recomposes `scopes` of one slot host in one pass and gives back the
576    // side effects that the pass registered.
577    fn recompose_host_scopes(
578        &mut self,
579        host: &Rc<SlotsHost>,
580        scopes: &[RecomposeScope],
581        batch: &Batch,
582        runtime_handle: &RuntimeHandle,
583    ) -> Result<Vec<Box<dyn FnOnce()>>, NodeError> {
584        let shared_state = host
585            .runtime_state()
586            .or_else(|| scopes.first().and_then(RecomposeScope::slots_runtime_state))
587            .unwrap_or_else(|| Rc::clone(&self.composer_state));
588        let threshold_ms = DIAGNOSTICS.recompose_scope_telemetry_ms.threshold_ms();
589        let _teardown = runtime::enter_state_teardown_scope();
590        let composer = Composer::for_pass(
591            self.parked_composer.take(),
592            shared_state,
593            Rc::clone(host),
594            self.applier_host(),
595            runtime_handle.clone(),
596            self.root,
597        );
598        composer.parent_stack().clear();
599        let (root, side_effects, requested_root_render, compact_applier) =
600            composer.install(|composer| {
601                let ((), outcome) = composer.try_with_slot_host_pass(
602                    Rc::clone(host),
603                    crate::slot::SlotPassMode::Recompose,
604                    |composer| match (batch, threshold_ms) {
605                        (Batch::Invalid, None) => {
606                            for scope in scopes {
607                                composer.recompose_group(scope);
608                            }
609                        }
610                        (Batch::Invalid, Some(threshold_ms)) => {
611                            recompose_timed(composer, scopes, threshold_ms);
612                        }
613                        (Batch::Waiting(content), _) => {
614                            composer.resume_waiting_bodies(content, scopes);
615                        }
616                    },
617                )?;
618                let root = composer.root();
619                let side_effects = composer.take_side_effects();
620                let requested_root_render = composer.take_root_render_request();
621                Ok((root, side_effects, requested_root_render, outcome.compacted))
622            })?;
623        self.apply_pass(host, runtime_handle, &composer, &side_effects)?;
624        if compact_applier {
625            self.applier.compact();
626            self.applier.borrow_dyn().clear_recycled_nodes();
627        }
628        if root.is_some() {
629            self.root = root;
630        }
631        if requested_root_render {
632            self.root_render_requested = true;
633        }
634        self.park_composer(host, composer);
635        Ok(side_effects)
636    }
637
638    /// Recomposes the scopes that state changes invalidated, until none remain or a root render is
639    /// requested. Returns `true` if a pass recomposed content.
640    pub fn process_invalid_scopes(&mut self) -> Result<bool, NodeError> {
641        self.process_invalid_scopes_within_budget()
642    }
643
644    // Derives the values of the derivations among `scopes` again and gives
645    // back the other scopes, or none when none is left. A derivation reads
646    // no slot: it needs a composer for the states it reads to subscribe it,
647    // and no pass over a slot table. A value that changed invalidates its
648    // readers, for the next batch.
649    fn rerun_derivations(
650        &mut self,
651        runtime: &RuntimeHandle,
652        mut scopes: Vec<RecomposeScope>,
653    ) -> Option<Vec<RecomposeScope>> {
654        if scopes.iter().any(RecomposeScope::is_derivation) {
655            self.rerun_derivations_of(runtime, &mut scopes);
656        }
657        if scopes.is_empty() {
658            runtime.recycle_scopes(scopes);
659            return None;
660        }
661        Some(scopes)
662    }
663
664    fn rerun_derivations_of(&mut self, runtime: &RuntimeHandle, scopes: &mut Vec<RecomposeScope>) {
665        let _teardown = runtime::enter_state_teardown_scope();
666        let host = self.slots_host();
667        let composer = Composer::for_pass(
668            self.parked_composer.take(),
669            Rc::clone(&self.composer_state),
670            Rc::clone(&host),
671            self.applier_host(),
672            runtime.clone(),
673            self.root,
674        );
675        composer.install(|composer| {
676            for scope in scopes.iter().filter(|scope| scope.is_derivation()) {
677                composer.recompose_group(scope);
678            }
679        });
680        self.park_composer(&host, composer);
681        scopes.retain(|scope| !scope.is_derivation());
682    }
683
684    /// Keeps the composer of a pass over the root slots for the next pass.
685    fn park_composer(&mut self, host: &Rc<SlotsHost>, composer: Composer) {
686        if Rc::ptr_eq(host, &self.slots) {
687            self.parked_composer = composer.park();
688        }
689    }
690
691    fn dispose_forgotten_movables(&mut self) -> Result<(), NodeError> {
692        let ids = self.runtime.take_forgotten_movables();
693        if ids.is_empty() {
694            return Ok(());
695        }
696        self.forget_movables(&ids)
697    }
698
699    // Out of line: a pass rarely forgets movable content, and the check
700    // before this runs on every pass.
701    #[inline(never)]
702    fn forget_movables(&mut self, ids: &[Key]) -> Result<(), NodeError> {
703        let runtime_handle = self.runtime_handle();
704        let _teardown = runtime::enter_state_teardown_scope();
705        let host = self.slots_host();
706        let composer = Composer::new_with_shared_state(
707            Rc::clone(&self.composer_state),
708            Rc::clone(&host),
709            self.applier_host(),
710            runtime_handle.clone(),
711            self.root,
712        );
713        composer.install(|composer| composer.forget_movables(ids))?;
714        self.apply_commands_and_updates_for_host(&host, &runtime_handle, &composer)
715    }
716
717    /// Applies the node updates that the runtime has queued to the applier, without recomposing.
718    pub fn flush_pending_node_updates(&mut self) -> Result<(), NodeError> {
719        let updates = self.runtime.take_updates();
720        let mut applier = self.applier.borrow_dyn();
721        for update in updates {
722            update.apply(&mut *applier)?;
723        }
724        Ok(())
725    }
726}
727
728// Recomposes each scope and reports the ones that took `threshold_ms` or
729// more, as scope telemetry asks.
730#[expect(clippy::print_stderr, reason = "a scope telemetry threshold asks")]
731fn recompose_timed(composer: &Composer, scopes: &[RecomposeScope], threshold_ms: f64) {
732    for scope in scopes {
733        let start = Instant::now();
734        composer.recompose_group(scope);
735        let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
736        if elapsed_ms >= threshold_ms {
737            eprintln!(
738                "[recompose-scope-telemetry] scope_id={} label={:?} elapsed_ms={elapsed_ms:.3} invalidation_sources={:?}",
739                scope.id(),
740                debug_scope_label(scope.id()),
741                debug_scope_invalidation_sources(scope.id())
742            );
743        }
744    }
745}
746
747// Where the scopes of a pass come from.
748enum Batch {
749    // Scopes that state changes invalidated.
750    Invalid,
751    // Bodies of pausable content that waited for this frame.
752    Waiting(PausableContent),
753}
754
755// Leaves the scopes that a root render request cut off for the next pass;
756// bodies of pausable content wait for a later frame.
757fn requeue_scopes(
758    runtime_handle: &RuntimeHandle,
759    batch: &Batch,
760    scope_groups: &[(Rc<SlotsHost>, Vec<RecomposeScope>)],
761) {
762    for scope in scope_groups.iter().flat_map(|(_, scopes)| scopes) {
763        match batch {
764            Batch::Invalid => runtime_handle.requeue_invalid_scope(scope),
765            Batch::Waiting(content) => content.keep_waiting(scope),
766        }
767    }
768}
769
770/// Gives a pass's scope lists back to the runtime, for the next pass to
771/// reuse their storage.
772fn recycle_scope_groups(
773    runtime: &RuntimeHandle,
774    scope_groups: SmallVec<[(Rc<SlotsHost>, Vec<RecomposeScope>); 1]>,
775) {
776    for (_, scopes) in scope_groups {
777        runtime.recycle_scopes(scopes);
778    }
779}
780
781impl<A: Applier + 'static> Drop for Composition<A> {
782    fn drop(&mut self) {
783        #[cfg(target_os = "android")]
784        self.runtime.restore_thread_state();
785        self.teardown = Some(runtime::enter_state_teardown_scope());
786    }
787}
788
789/// The largest sizes that the command queues and the side effect list reached during one
790/// composition pass, for debugging memory use.
791#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
792pub struct CompositionPassDebugStats {
793    /// Most node commands queued at once.
794    pub commands_len: usize,
795    /// Largest capacity reserved for node commands.
796    pub commands_cap: usize,
797    /// Most bytes of command arguments queued at once.
798    pub command_payload_len_bytes: usize,
799    /// Largest capacity reserved for command arguments, in bytes.
800    pub command_payload_cap_bytes: usize,
801    /// Most child list updates queued at once.
802    pub sync_children_len: usize,
803    /// Largest capacity reserved for child list updates.
804    pub sync_children_cap: usize,
805    /// Most child ids stored for child list updates.
806    pub sync_child_ids_len: usize,
807    /// Largest capacity reserved for those child ids.
808    pub sync_child_ids_cap: usize,
809    /// Most side effects queued at once.
810    pub side_effects_len: usize,
811    /// Largest capacity reserved for the side effects.
812    pub side_effects_cap: usize,
813}