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