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