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        mut 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        crate::composer::recycle_commands(commands);
187        if result.is_err() {
188            self.abandon_host_after_apply_failure(host);
189        }
190        result
191    }
192
193    fn render_root_pass(&mut self, key: Key, content: &mut dyn FnMut()) -> Result<(), NodeError> {
194        self.root_key = Some(key);
195        self.root_render_requested = false;
196        let runtime_handle = self.runtime_handle();
197        runtime_handle.drain_ui();
198        let side_effects = {
199            let _teardown = runtime::enter_state_teardown_scope();
200            let composer = Composer::new_with_shared_state(
201                Rc::clone(&self.composer_state),
202                Rc::clone(&self.slots),
203                self.applier.clone(),
204                runtime_handle.clone(),
205                self.root,
206            );
207            let (root, commands, side_effects, compact_applier) = composer.install(|composer| {
208                let ((), outcome) = composer.try_with_slot_host_pass(
209                    Rc::clone(&self.slots),
210                    crate::slot::SlotPassMode::Compose,
211                    |composer| composer.with_group(key, |_| content()),
212                )?;
213                let root = composer.root();
214                let commands = composer.take_commands();
215                let side_effects = composer.take_side_effects();
216                Ok((root, commands, side_effects, outcome.compacted))
217            })?;
218            self.record_pass_stats(&commands, &side_effects);
219            self.apply_commands_and_updates_for_host(
220                &Rc::clone(&self.slots),
221                &runtime_handle,
222                commands,
223            )?;
224            if compact_applier {
225                self.applier.compact();
226                self.applier.borrow_dyn().clear_recycled_nodes();
227            }
228
229            self.root = root;
230            side_effects
231        };
232        runtime_handle.drain_ui();
233        for effect in side_effects {
234            effect();
235        }
236        runtime_handle.drain_ui();
237        self.maybe_dump_slot_table("root_render_pass");
238        Ok(())
239    }
240
241    fn reconcile_with_content(
242        &mut self,
243        key: Key,
244        content: &mut dyn FnMut(),
245    ) -> Result<bool, NodeError> {
246        self.root_key = Some(key);
247        let mut did_work = false;
248        let mut root_render_replays = 0usize;
249        loop {
250            did_work |= self.process_invalid_scopes_until_root_request()?;
251            if !self.take_root_render_request() {
252                return Ok(did_work);
253            }
254
255            root_render_replays += 1;
256            if root_render_replays > ROOT_RENDER_REPLAY_LIMIT {
257                log::error!(
258                    "root render replay looped past {ROOT_RENDER_REPLAY_LIMIT} iterations; breaking to keep UI responsive"
259                );
260                return Err(NodeError::RecompositionLimitExceeded {
261                    operation: "root render replay",
262                    limit: ROOT_RENDER_REPLAY_LIMIT,
263                });
264            }
265
266            self.render_root_pass(key, content)?;
267            did_work = true;
268        }
269    }
270
271    pub fn render(&mut self, key: Key, mut content: impl FnMut()) -> Result<(), NodeError> {
272        self.reset_last_pass_stats();
273        self.render_root_pass(key, &mut content)?;
274        let _ = self.process_invalid_scopes()?;
275        Ok(())
276    }
277
278    /// Perform a root render and continue replaying any resulting root-render
279    /// requests until the composition reaches a stable fixpoint.
280    pub fn render_stable(&mut self, key: Key, mut content: impl FnMut()) -> Result<(), NodeError> {
281        self.reset_last_pass_stats();
282        self.render_root_pass(key, &mut content)?;
283        let _ = self.reconcile_with_content(key, &mut content)?;
284        Ok(())
285    }
286
287    /// Process invalid scopes and any resulting root-render requests until the
288    /// composition reaches a stable fixpoint for the supplied root content.
289    pub fn reconcile(&mut self, key: Key, mut content: impl FnMut()) -> Result<bool, NodeError> {
290        self.reconcile_with_content(key, &mut content)
291    }
292
293    /// Returns true if composition needs to process invalid scopes (recompose).
294    ///
295    /// This checks both:
296    /// - `has_updates()`: composition scopes that were invalidated by state changes
297    /// - `needs_frame()`: animation callbacks that may have pending work
298    ///
299    /// Note: For scroll performance, ensure scroll state changes use `Cell<T>` instead
300    /// of `MutableState<T>` to avoid triggering recomposition on every scroll frame.
301    pub fn should_render(&self) -> bool {
302        self.root_render_requested || self.runtime.needs_frame() || self.runtime.has_updates()
303    }
304
305    /// Whether any composition scope is actually invalid, i.e. whether running
306    /// the composable tree could produce a different result than last time.
307    ///
308    /// Deliberately excludes [`Runtime::needs_frame`], which [`Self::should_render`]
309    /// includes. An armed frame callback means the *runtime* owes someone a
310    /// tick - a future to resume, a dispatcher to drain - and every app with a
311    /// game loop or a polling effect has one armed at all times. Re-running the
312    /// composition for it recomposes a tree that nothing invalidated. Callers
313    /// deciding "should I tick" want [`Self::should_render`]; callers deciding
314    /// "should I recompose" want this.
315    ///
316    /// It is only meaningful *after* the frame callbacks have been drained: a
317    /// callback that writes state invalidates its readers as it runs, so the
318    /// answer is a question about work already discovered, not work still to
319    /// come.
320    pub fn should_recompose(&self) -> bool {
321        self.root_render_requested || self.runtime.has_updates()
322    }
323
324    pub fn runtime_handle(&self) -> RuntimeHandle {
325        self.runtime.handle()
326    }
327
328    pub fn applier_mut(&mut self) -> ApplierGuard<'_, A> {
329        self.applier.borrow_typed()
330    }
331
332    pub fn root(&self) -> Option<NodeId> {
333        self.root
334    }
335
336    pub fn debug_dump_slot_table_groups(&self) -> Vec<(usize, Key, Option<ScopeId>, usize)> {
337        self.slots.borrow().debug_dump_groups()
338    }
339
340    pub fn debug_dump_slot_entries(&self) -> Vec<crate::SlotDebugEntry> {
341        self.slots.borrow().debug_dump_slot_entries()
342    }
343
344    pub fn slot_table_heap_bytes(&self) -> usize {
345        self.slots.borrow().heap_bytes()
346    }
347
348    pub fn debug_slot_table_stats(&self) -> SlotTableDebugStats {
349        self.slots.debug_stats()
350    }
351
352    pub fn debug_slot_snapshot(&self) -> SlotDebugSnapshot {
353        self.slots.debug_snapshot()
354    }
355
356    pub fn debug_last_pass_stats(&self) -> CompositionPassDebugStats {
357        self.last_pass_stats
358    }
359
360    #[cfg(test)]
361    pub(crate) fn debug_validate_slots(&self) -> Result<(), crate::slot::SlotInvariantError> {
362        let table = self.slots.borrow();
363        table.validate()?;
364        self.composer_state
365            .validate_host_retention(self.slots.as_ref(), &table)
366    }
367
368    fn process_invalid_scopes_until_root_request(&mut self) -> Result<bool, NodeError> {
369        let runtime_handle = self.runtime_handle();
370        let mut did_recompose = false;
371        let mut loop_count = 0;
372        loop {
373            loop_count += 1;
374            if loop_count > ROOT_RENDER_REPLAY_LIMIT {
375                log::error!(
376                    "process_invalid_scopes looped past {ROOT_RENDER_REPLAY_LIMIT} iterations; breaking to keep UI responsive"
377                );
378                return Err(NodeError::RecompositionLimitExceeded {
379                    operation: "process_invalid_scopes",
380                    limit: ROOT_RENDER_REPLAY_LIMIT,
381                });
382            }
383            runtime_handle.drain_ui();
384            self.dispose_forgotten_movables()?;
385            let Some(scopes) = live_invalidated_scopes(&runtime_handle) else {
386                break;
387            };
388            if scopes.is_empty() {
389                continue;
390            }
391            did_recompose |= recomposes_content(&scopes);
392            let runtime_clone = runtime_handle.clone();
393            let root_host = self.slots_host();
394            let mut scope_groups: Vec<(Rc<SlotsHost>, Vec<RecomposeScope>)> = Vec::new();
395            let mut scope_group_index: HashMap<usize, usize> = HashMap::default();
396            for scope in scopes {
397                let host = scope
398                    .slots_runtime_state()
399                    .and_then(|state| {
400                        scope
401                            .slots_storage_key()
402                            .and_then(|storage_key| state.host_for_storage_key(storage_key))
403                    })
404                    .or_else(|| {
405                        scope.slots_storage_key().and_then(|storage_key| {
406                            self.composer_state.host_for_storage_key(storage_key)
407                        })
408                    })
409                    .unwrap_or_else(|| Rc::clone(&root_host));
410                let host_key = host.storage_key();
411                if let Some(index) = scope_group_index.get(&host_key).copied() {
412                    scope_groups[index].1.push(scope);
413                } else {
414                    scope_group_index.insert(host_key, scope_groups.len());
415                    scope_groups.push((host, vec![scope]));
416                }
417            }
418            let mut host_group_index = 0usize;
419            while host_group_index < scope_groups.len() {
420                let (host, scopes) = &scope_groups[host_group_index];
421                let scope_telemetry_threshold_ms =
422                    crate::env_threshold_ms!("CRANPOSE_RECOMPOSE_SCOPE_TELEMETRY_MS");
423                let shared_state = host
424                    .runtime_state()
425                    .or_else(|| scopes.first().and_then(RecomposeScope::slots_runtime_state))
426                    .unwrap_or_else(|| Rc::clone(&self.composer_state));
427                let side_effects = {
428                    let _teardown = runtime::enter_state_teardown_scope();
429                    let composer = Composer::new_with_shared_state(
430                        shared_state,
431                        Rc::clone(host),
432                        self.applier_host(),
433                        runtime_clone.clone(),
434                        self.root,
435                    );
436                    composer.parent_stack().clear();
437                    let (root, commands, side_effects, requested_root_render, compact_applier) =
438                        composer.install(|composer| {
439                            let ((), outcome) = composer.try_with_slot_host_pass(
440                                Rc::clone(host),
441                                crate::slot::SlotPassMode::Recompose,
442                                |composer| {
443                                    for scope in scopes {
444                                        if let Some(threshold_ms) = scope_telemetry_threshold_ms {
445                                            let start = Instant::now();
446                                            composer.recompose_group(scope);
447                                            let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
448                                            if elapsed_ms >= threshold_ms {
449                                                eprintln!(
450                                                    "[recompose-scope-telemetry] scope_id={} label={:?} elapsed_ms={elapsed_ms:.3} invalidation_sources={:?}",
451                                                    scope.id(),
452                                                    debug_scope_label(scope.id()),
453                                                    debug_scope_invalidation_sources(scope.id())
454                                                );
455                                            }
456                                        } else {
457                                            composer.recompose_group(scope);
458                                        }
459                                    }
460                                },
461                            )?;
462                            let root = composer.root();
463                            let commands = composer.take_commands();
464                            let side_effects = composer.take_side_effects();
465                            let requested_root_render = composer.take_root_render_request();
466                            Ok((
467                                root,
468                                commands,
469                                side_effects,
470                                requested_root_render,
471                                outcome.compacted,
472                            ))
473                        })?;
474                    self.record_pass_stats(&commands, &side_effects);
475                    self.apply_commands_and_updates_for_host(host, &runtime_handle, commands)?;
476                    if compact_applier {
477                        self.applier.compact();
478                        self.applier.borrow_dyn().clear_recycled_nodes();
479                    }
480                    if root.is_some() {
481                        self.root = root;
482                    }
483                    if requested_root_render {
484                        self.root_render_requested = true;
485                    }
486                    side_effects
487                };
488                runtime_handle.drain_ui();
489                for effect in side_effects {
490                    effect();
491                }
492                runtime_handle.drain_ui();
493                self.maybe_dump_slot_table("recompose_pass");
494                if self.root_render_requested {
495                    for (_, remaining_scopes) in scope_groups.iter().skip(host_group_index + 1) {
496                        for scope in remaining_scopes {
497                            runtime_handle.requeue_invalid_scope(scope.id(), scope.downgrade());
498                        }
499                    }
500                    break;
501                }
502                host_group_index += 1;
503            }
504            if self.root_render_requested {
505                break;
506            }
507        }
508        self.finalize_runtime_state();
509        Ok(did_recompose)
510    }
511
512    pub fn process_invalid_scopes(&mut self) -> Result<bool, NodeError> {
513        self.process_invalid_scopes_until_root_request()
514    }
515
516    fn dispose_forgotten_movables(&mut self) -> Result<(), NodeError> {
517        let runtime_handle = self.runtime_handle();
518        let ids = runtime_handle.take_forgotten_movables();
519        if ids.is_empty() {
520            return Ok(());
521        }
522        let host = self.slots_host();
523        let composer = Composer::new_with_shared_state(
524            Rc::clone(&self.composer_state),
525            Rc::clone(&host),
526            self.applier_host(),
527            runtime_handle.clone(),
528            self.root,
529        );
530        let commands = composer.install(|composer| {
531            composer.forget_movables(&ids)?;
532            Ok::<_, NodeError>(composer.take_commands())
533        })?;
534        self.apply_commands_and_updates_for_host(&host, &runtime_handle, commands)
535    }
536}
537
538/// Whether running these scopes can change what the composition shows: a pass
539/// that only recomputes derived states changes nothing unless a derived value
540/// moved, and then its readers run in a pass of their own.
541fn recomposes_content(scopes: &[RecomposeScope]) -> bool {
542    scopes.iter().any(|scope| !scope.is_derivation())
543}
544
545fn live_invalidated_scopes(runtime_handle: &RuntimeHandle) -> Option<Vec<RecomposeScope>> {
546    let pending = runtime_handle.take_invalidated_scopes();
547    if pending.is_empty() {
548        return None;
549    }
550    let mut scopes = Vec::with_capacity(pending.len());
551    for (id, weak) in pending {
552        if let Some(inner) = weak.upgrade() {
553            scopes.push(RecomposeScope { inner });
554        } else {
555            runtime_handle.mark_scope_recomposed(id);
556        }
557    }
558    Some(scopes)
559}
560
561impl<A: Applier + 'static> Composition<A> {
562    pub fn flush_pending_node_updates(&mut self) -> Result<(), NodeError> {
563        let updates = self.runtime_handle().take_updates();
564        let mut applier = self.applier.borrow_dyn();
565        for update in updates {
566            update.apply(&mut *applier)?;
567        }
568        Ok(())
569    }
570}
571
572impl<A: Applier + 'static> Drop for Composition<A> {
573    fn drop(&mut self) {
574        self.teardown = Some(runtime::enter_state_teardown_scope());
575    }
576}