Skip to main content

cranpose_core/
composition.rs

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