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