Skip to main content

cranpose_core/
composition.rs

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