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    /// The scopes grouped by the slot host each recomposes in, in the order
369    /// each host first appears.
370    fn group_scopes_by_host(
371        &self,
372        scopes: Vec<RecomposeScope>,
373    ) -> Vec<(Rc<SlotsHost>, Vec<RecomposeScope>)> {
374        let root_host = self.slots_host();
375        let mut scope_groups: Vec<(Rc<SlotsHost>, Vec<RecomposeScope>)> = Vec::new();
376        let mut scope_group_index: HashMap<usize, usize> = HashMap::default();
377        let mut previous_host: Option<(_, usize)> = None;
378        for scope in scopes {
379            let identity = scope.slots_host_identity();
380            if let Some((previous_identity, index)) = previous_host
381                && previous_identity == identity
382            {
383                scope_groups[index].1.push(scope);
384                continue;
385            }
386            let host = scope
387                .slots_runtime_state()
388                .and_then(|state| {
389                    scope
390                        .slots_storage_key()
391                        .and_then(|storage_key| state.host_for_storage_key(storage_key))
392                })
393                .or_else(|| {
394                    scope.slots_storage_key().and_then(|storage_key| {
395                        self.composer_state.host_for_storage_key(storage_key)
396                    })
397                })
398                .unwrap_or_else(|| Rc::clone(&root_host));
399            let host_key = host.storage_key();
400            let index = if let Some(index) = scope_group_index.get(&host_key).copied() {
401                scope_groups[index].1.push(scope);
402                index
403            } else {
404                scope_group_index.insert(host_key, scope_groups.len());
405                scope_groups.push((host, vec![scope]));
406                scope_groups.len() - 1
407            };
408            previous_host = Some((identity, index));
409        }
410        scope_groups
411    }
412
413    fn process_invalid_scopes_until_root_request(&mut self) -> Result<bool, NodeError> {
414        let runtime_handle = self.runtime_handle();
415        let mut did_recompose = false;
416        let mut loop_count = 0;
417        loop {
418            loop_count += 1;
419            if loop_count > ROOT_RENDER_REPLAY_LIMIT {
420                log::error!(
421                    "process_invalid_scopes looped past {ROOT_RENDER_REPLAY_LIMIT} iterations; breaking to keep UI responsive"
422                );
423                return Err(NodeError::RecompositionLimitExceeded {
424                    operation: "process_invalid_scopes",
425                    limit: ROOT_RENDER_REPLAY_LIMIT,
426                });
427            }
428            runtime_handle.drain_ui();
429            self.dispose_forgotten_movables()?;
430            let Some(scopes) = runtime_handle.take_invalidated_scopes() else {
431                break;
432            };
433            if scopes.is_empty() {
434                continue;
435            }
436            did_recompose |= recomposes_content(&scopes);
437            let runtime_clone = runtime_handle.clone();
438            let scope_groups = self.group_scopes_by_host(scopes);
439            let mut host_group_index = 0usize;
440            while host_group_index < scope_groups.len() {
441                let (host, scopes) = &scope_groups[host_group_index];
442                let scope_telemetry_threshold_ms =
443                    crate::env_threshold_ms!("CRANPOSE_RECOMPOSE_SCOPE_TELEMETRY_MS");
444                let shared_state = host
445                    .runtime_state()
446                    .or_else(|| scopes.first().and_then(RecomposeScope::slots_runtime_state))
447                    .unwrap_or_else(|| Rc::clone(&self.composer_state));
448                let side_effects = {
449                    let _teardown = runtime::enter_state_teardown_scope();
450                    let composer = Composer::new_with_shared_state(
451                        shared_state,
452                        Rc::clone(host),
453                        self.applier_host(),
454                        runtime_clone.clone(),
455                        self.root,
456                    );
457                    composer.parent_stack().clear();
458                    let (root, commands, side_effects, requested_root_render, compact_applier) =
459                        composer.install(|composer| {
460                            let ((), outcome) = composer.try_with_slot_host_pass(
461                                Rc::clone(host),
462                                crate::slot::SlotPassMode::Recompose,
463                                |composer| {
464                                    for scope in scopes {
465                                        if let Some(threshold_ms) = scope_telemetry_threshold_ms {
466                                            let start = Instant::now();
467                                            composer.recompose_group(scope);
468                                            let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
469                                            if elapsed_ms >= threshold_ms {
470                                                eprintln!(
471                                                    "[recompose-scope-telemetry] scope_id={} label={:?} elapsed_ms={elapsed_ms:.3} invalidation_sources={:?}",
472                                                    scope.id(),
473                                                    debug_scope_label(scope.id()),
474                                                    debug_scope_invalidation_sources(scope.id())
475                                                );
476                                            }
477                                        } else {
478                                            composer.recompose_group(scope);
479                                        }
480                                    }
481                                },
482                            )?;
483                            let root = composer.root();
484                            let commands = composer.take_commands();
485                            let side_effects = composer.take_side_effects();
486                            let requested_root_render = composer.take_root_render_request();
487                            Ok((
488                                root,
489                                commands,
490                                side_effects,
491                                requested_root_render,
492                                outcome.compacted,
493                            ))
494                        })?;
495                    self.record_pass_stats(&commands, &side_effects);
496                    self.apply_commands_and_updates_for_host(host, &runtime_handle, commands)?;
497                    if compact_applier {
498                        self.applier.compact();
499                        self.applier.borrow_dyn().clear_recycled_nodes();
500                    }
501                    if root.is_some() {
502                        self.root = root;
503                    }
504                    if requested_root_render {
505                        self.root_render_requested = true;
506                    }
507                    side_effects
508                };
509                runtime_handle.drain_ui();
510                for effect in side_effects {
511                    effect();
512                }
513                runtime_handle.drain_ui();
514                self.maybe_dump_slot_table("recompose_pass");
515                if self.root_render_requested {
516                    for (_, remaining_scopes) in scope_groups.iter().skip(host_group_index + 1) {
517                        for scope in remaining_scopes {
518                            runtime_handle.requeue_invalid_scope(scope);
519                        }
520                    }
521                    break;
522                }
523                host_group_index += 1;
524            }
525            if self.root_render_requested {
526                break;
527            }
528        }
529        self.finalize_runtime_state();
530        Ok(did_recompose)
531    }
532
533    pub fn process_invalid_scopes(&mut self) -> Result<bool, NodeError> {
534        self.process_invalid_scopes_until_root_request()
535    }
536
537    fn dispose_forgotten_movables(&mut self) -> Result<(), NodeError> {
538        let runtime_handle = self.runtime_handle();
539        let ids = runtime_handle.take_forgotten_movables();
540        if ids.is_empty() {
541            return Ok(());
542        }
543        let host = self.slots_host();
544        let composer = Composer::new_with_shared_state(
545            Rc::clone(&self.composer_state),
546            Rc::clone(&host),
547            self.applier_host(),
548            runtime_handle.clone(),
549            self.root,
550        );
551        let commands = composer.install(|composer| {
552            composer.forget_movables(&ids)?;
553            Ok::<_, NodeError>(composer.take_commands())
554        })?;
555        self.apply_commands_and_updates_for_host(&host, &runtime_handle, commands)
556    }
557}
558
559/// Whether running these scopes can change what the composition shows: a pass
560/// that only recomputes derived states changes nothing unless a derived value
561/// moved, and then its readers run in a pass of their own.
562fn recomposes_content(scopes: &[RecomposeScope]) -> bool {
563    scopes.iter().any(|scope| !scope.is_derivation())
564}
565
566impl<A: Applier + 'static> Composition<A> {
567    pub fn flush_pending_node_updates(&mut self) -> Result<(), NodeError> {
568        let updates = self.runtime_handle().take_updates();
569        let mut applier = self.applier.borrow_dyn();
570        for update in updates {
571            update.apply(&mut *applier)?;
572        }
573        Ok(())
574    }
575}
576
577impl<A: Applier + 'static> Drop for Composition<A> {
578    fn drop(&mut self) {
579        self.teardown = Some(runtime::enter_state_teardown_scope());
580    }
581}