Skip to main content

cranpose_core/
subcompose.rs

1//! State tracking for measure-time subcomposition.
2//!
3//! The [`SubcomposeState`] keeps book of which slots are active, which nodes can
4//! be reused, and which precompositions need to be disposed. Reuse follows a
5//! two-phase lookup: first [`SlotId`]s that match exactly are preferred. If no
6//! exact match exists, the [`SlotReusePolicy`] is consulted to determine whether
7//! a node produced for another slot is compatible with the requested slot.
8
9use std::{any::Any, fmt, rc::Rc};
10
11use smallvec::SmallVec;
12
13use crate::{
14    CallbackHolder, NodeId, RecomposeScope, SlotTable, SlotsHost, collections::map::HashMap,
15};
16
17mod reuse_pool;
18mod slot_reuse_policy;
19
20use reuse_pool::ReusePool;
21pub use slot_reuse_policy::{
22    ContentTypeReusePolicy, DefaultSlotReusePolicy, SlotReusePolicy, TypedSlot,
23};
24
25/// One group of a slot table, for debugging: the group index, its call site key, its scope id and
26/// the length of its subtree.
27pub type DebugSlotGroup = (usize, crate::Key, Option<usize>, usize);
28
29/// Identifier for a subcomposed slot.
30///
31/// This mirrors the `slotId` concept in Jetpack Compose where callers provide
32/// stable identifiers for reusable children during measure-time composition.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct SlotId(pub u64);
35
36impl SlotId {
37    /// Creates a slot id from `raw`.
38    #[inline]
39    pub fn new(raw: u64) -> Self {
40        Self(raw)
41    }
42
43    /// Returns the identifier as a number.
44    #[inline]
45    pub fn raw(self) -> u64 {
46        self.0
47    }
48}
49
50#[doc(hidden)]
51pub struct ExactSlotActivation<'a> {
52    pub nodes: &'a [NodeId],
53    pub was_recycled: bool,
54}
55
56/// Evicted roots and the slot state that stays alive until their nodes are disposed.
57/// Pass this batch to [`crate::Composer::dispose_subcomposed_nodes`].
58#[derive(Default)]
59pub struct SubcomposeDisposal {
60    pub(crate) nodes: Vec<NodeId>,
61    pub(crate) slot_hosts: Vec<Rc<SlotsHost>>,
62}
63
64impl SubcomposeDisposal {
65    /// Returns the roots awaiting disposal.
66    ///
67    /// ```
68    /// use cranpose_core::SubcomposeState;
69    /// let mut state = SubcomposeState::default();
70    /// let disposed = state.finish_pass();
71    /// assert!(disposed.nodes().is_empty());
72    /// ```
73    pub fn nodes(&self) -> &[NodeId] {
74        &self.nodes
75    }
76
77    fn append(&mut self, mut other: Self) {
78        self.nodes.append(&mut other.nodes);
79        self.slot_hosts.append(&mut other.slot_hosts);
80    }
81}
82
83/// What the state keeps for one slot, found with one lookup: activating a
84/// kept slot reads all of it.
85#[derive(Default)]
86struct SlotRecord {
87    /// The root nodes the slot's content composed, once it composed.
88    nodes: Option<Vec<NodeId>>,
89    /// The recompose scopes of the slot's content.
90    scopes: Option<Vec<RecomposeScope>>,
91    /// The slot is active: the last pass kept it, or the current one did.
92    live: bool,
93    /// The pass that activated the slot; zero once it left the active set.
94    active_pass: u64,
95    /// How many of the slot's nodes wait in the reuse pools.
96    reusable_nodes: usize,
97    /// The slot waits in the reuse pool and can be reactivated as it was.
98    exact_reactivation: bool,
99    content_type: Option<u64>,
100    composition: Option<Rc<SlotsHost>>,
101    callback: Option<CallbackHolder>,
102    capture_key: Option<RetainedCaptureKey>,
103    /// The content generation and owner epoch the slot last composed under.
104    composed_generation: Option<(u64, u64)>,
105}
106
107impl SlotRecord {
108    /// Whether a scope of the slot's content must run before the slot shows
109    /// what it composed: an invalid one, or one a deactivation left inactive.
110    fn has_stale_scopes(&self) -> bool {
111        self.scopes
112            .iter()
113            .flatten()
114            .any(|scope| scope.is_invalid() || !scope.is_active())
115    }
116
117    fn has_invalid_scopes(&self) -> bool {
118        self.scopes.iter().flatten().any(RecomposeScope::is_invalid)
119    }
120
121    /// Whether the record holds nothing a later pass reads.
122    fn is_empty(&self) -> bool {
123        self.nodes.is_none()
124            && self.scopes.is_none()
125            && !self.live
126            && self.reusable_nodes == 0
127            && !self.exact_reactivation
128            && self.content_type.is_none()
129            && self.composition.is_none()
130            && self.callback.is_none()
131            && self.capture_key.is_none()
132            && self.composed_generation.is_none()
133    }
134}
135
136/// Tracks the state of nodes produced by subcomposition, enabling reuse between
137/// measurement passes.
138pub struct SubcomposeState {
139    slots: HashMap<SlotId, SlotRecord>,
140    node_to_slot: HashMap<NodeId, SlotId>,
141    active_order: Vec<SlotId>,
142    /// The running pass, counted from one: a slot activated in it has it as
143    /// its `active_pass`.
144    pass: u64,
145    pool: ReusePool,
146    precomposed_nodes: HashMap<SlotId, Vec<NodeId>>,
147    policy: Box<dyn SlotReusePolicy>,
148    pub(crate) current_index: usize,
149    pub(crate) reusable_count: usize,
150    pub(crate) precomposed_count: usize,
151    last_slot_reused: Option<bool>,
152    content_generation: std::cell::Cell<u64>,
153    /// The content generation from which slots compose under changed
154    /// static composition locals; zero while none changed.
155    locals_generation: std::cell::Cell<u64>,
156}
157
158struct RetainedCaptureKey {
159    value: Box<dyn Any>,
160    eq: fn(&dyn Any, &dyn Any) -> bool,
161}
162
163fn retained_capture_key_eq<K: PartialEq + 'static>(stored: &dyn Any, candidate: &dyn Any) -> bool {
164    match (stored.downcast_ref::<K>(), candidate.downcast_ref::<K>()) {
165        (Some(stored), Some(candidate)) => stored == candidate,
166        _ => false,
167    }
168}
169
170impl fmt::Debug for SubcomposeState {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.debug_struct("SubcomposeState")
173            .field("slots_count", &self.slots.len())
174            .field("node_to_slot", &self.node_to_slot)
175            .field("active_order", &self.active_order)
176            .field("reusable_by_type_count", &self.pool.type_count())
177            .field("reusable_untyped_count", &self.pool.untyped_len())
178            .field("precomposed_nodes", &self.precomposed_nodes)
179            .field("current_index", &self.current_index)
180            .field("reusable_count", &self.reusable_count)
181            .field("precomposed_count", &self.precomposed_count)
182            .finish()
183    }
184}
185
186impl Default for SubcomposeState {
187    fn default() -> Self {
188        Self::new(Box::new(DefaultSlotReusePolicy))
189    }
190}
191
192impl SubcomposeState {
193    /// Creates a new [`SubcomposeState`] using the supplied reuse policy.
194    pub fn new(policy: Box<dyn SlotReusePolicy>) -> Self {
195        Self {
196            slots: HashMap::default(),
197            node_to_slot: HashMap::default(),
198            active_order: Vec::new(),
199            pass: 1,
200            pool: ReusePool::new(),
201            precomposed_nodes: HashMap::default(),
202            policy,
203            current_index: 0,
204            reusable_count: 0,
205            precomposed_count: 0,
206            last_slot_reused: None,
207            content_generation: std::cell::Cell::new(0),
208            locals_generation: std::cell::Cell::new(0),
209        }
210    }
211
212    /// Sets the policy used for future reuse decisions.
213    pub fn set_policy(&mut self, policy: Box<dyn SlotReusePolicy>) {
214        self.policy = policy;
215    }
216
217    /// Sets how many composed nodes the reusable pool keeps: `per_type` for each content type and
218    /// `untyped` for nodes without one. Nodes over a limit are disposed.
219    pub fn set_reusable_pool_limits(&mut self, per_type: usize, untyped: usize) {
220        self.pool.set_limits(per_type, untyped);
221    }
222
223    fn record(&mut self, slot_id: SlotId) -> &mut SlotRecord {
224        self.slots.entry(slot_id).or_default()
225    }
226
227    fn slot_nodes(&self, slot_id: SlotId) -> Option<&[NodeId]> {
228        self.slots.get(&slot_id)?.nodes.as_deref()
229    }
230
231    fn typed_slot(&self, slot_id: SlotId) -> TypedSlot {
232        TypedSlot {
233            id: slot_id,
234            content_type: self.get_content_type(slot_id),
235        }
236    }
237
238    fn slot_active_in_pass(&self, slot_id: SlotId) -> bool {
239        self.slots
240            .get(&slot_id)
241            .is_some_and(|record| record.active_pass == self.pass)
242    }
243
244    fn clear_exact_reactivation(&mut self, slot_id: SlotId) {
245        if let Some(record) = self.slots.get_mut(&slot_id) {
246            record.exact_reactivation = false;
247        }
248    }
249
250    /// Registers a content type for a slot.
251    ///
252    /// Call this before subcomposing an item to enable content-type-aware slot reuse.
253    pub fn register_content_type(&mut self, slot_id: SlotId, content_type: u64) {
254        self.record(slot_id).content_type = Some(content_type);
255    }
256
257    /// Updates the content type for a slot, handling optional content types.
258    ///
259    /// If `content_type` is `Some(type)`, registers the type for the slot.
260    /// If `content_type` is `None`, removes any previously registered type.
261    /// This ensures stale types don't drive incorrect reuse.
262    pub fn update_content_type(&mut self, slot_id: SlotId, content_type: Option<u64>) {
263        match content_type {
264            Some(content_type) => self.register_content_type(slot_id, content_type),
265            None => {
266                if let Some(record) = self.slots.get_mut(&slot_id) {
267                    record.content_type = None;
268                }
269            }
270        }
271    }
272
273    /// Returns the content type for a slot, if registered.
274    pub fn get_content_type(&self, slot_id: SlotId) -> Option<u64> {
275        self.slots.get(&slot_id)?.content_type
276    }
277
278    /// Returns the active slot that owns a registered root node.
279    ///
280    /// Descendants must be resolved to their slot root by the caller. Reusable
281    /// and removed slots are excluded.
282    pub fn active_slot_for_node(&self, node_id: NodeId) -> Option<SlotId> {
283        let slot = *self.node_to_slot.get(&node_id)?;
284        self.slot_is_active(slot).then_some(slot)
285    }
286
287    /// Starts a new subcompose pass.
288    ///
289    /// Call this before subcomposing the current frame so the state can
290    /// track which slots are active and dispose the inactive ones later.
291    pub fn begin_pass(&mut self) {
292        self.current_index = 0;
293        self.pass += 1;
294    }
295
296    /// Returns the current active slot cursor for the in-progress pass.
297    pub fn active_slot_cursor(&self) -> usize {
298        self.current_index
299    }
300
301    /// Restores the active slot cursor for work that should not become part of
302    /// the rendered active set, such as lazy-list prefetch measurement.
303    pub fn restore_active_slot_cursor(&mut self, cursor: usize) {
304        self.current_index = cursor.min(self.active_order.len());
305    }
306
307    /// Moves an active slot out of the rendered set and into the reusable pool.
308    pub fn recycle_active_slot(&mut self, slot_id: SlotId) -> SubcomposeDisposal {
309        self.recycle_active_slot_internal(slot_id, false)
310    }
311
312    /// Like `recycle_active_slot`, for a slot that was composed ahead of need. The slot can be
313    /// reactivated as it was when its id is asked for again, unless its effects were forgotten.
314    pub fn recycle_prefetched_active_slot(&mut self, slot_id: SlotId) -> SubcomposeDisposal {
315        self.recycle_active_slot_internal(slot_id, true)
316    }
317
318    /// Recycles every active slot that `predicate` accepts and that the current pass has not used.
319    /// Returns the nodes and slot hosts that were disposed.
320    pub fn recycle_active_slots_where(
321        &mut self,
322        mut predicate: impl FnMut(SlotId) -> bool,
323    ) -> SubcomposeDisposal {
324        let slots: Vec<_> = self
325            .active_order
326            .iter()
327            .copied()
328            .filter(|slot| !self.slot_active_in_pass(*slot))
329            .filter(|slot| predicate(*slot))
330            .collect();
331        let mut disposed = SubcomposeDisposal::default();
332        for slot in slots {
333            disposed.append(self.recycle_active_slot(slot));
334        }
335        disposed
336    }
337
338    fn recycle_active_slot_internal(
339        &mut self,
340        slot_id: SlotId,
341        allow_exact_reactivation: bool,
342    ) -> SubcomposeDisposal {
343        let Some(position) = self
344            .active_order
345            .iter()
346            .position(|candidate| *candidate == slot_id)
347        else {
348            return SubcomposeDisposal::default();
349        };
350        self.active_order.remove(position);
351        if position < self.current_index {
352            self.current_index = self.current_index.saturating_sub(1);
353        }
354        self.move_slot_to_reusable(slot_id, allow_exact_reactivation);
355        self.enforce_reusable_pool_limits()
356    }
357
358    /// Finishes a subcompose pass, disposing slots that were not used.
359    pub fn finish_pass(&mut self) -> SubcomposeDisposal {
360        let mut disposed = self.dispose_or_reuse_starting_from_index(self.current_index);
361        for (slot, node_id) in self.pool.end_pass() {
362            self.dispose_reusable_node(slot, node_id, &mut disposed);
363        }
364        disposed
365    }
366
367    /// Returns the SlotsHost for the given slot ID, creating a new one if it doesn't exist.
368    /// Each slot gets its own isolated slot table, avoiding cursor-based conflicts when
369    /// items are subcomposed in different orders.
370    pub fn get_or_create_slots(&mut self, slot_id: SlotId) -> Rc<SlotsHost> {
371        Rc::clone(
372            self.record(slot_id)
373                .composition
374                .get_or_insert_with(|| Rc::new(SlotsHost::new(SlotTable::new()))),
375        )
376    }
377
378    /// Returns the latest callback holder for the given slot, creating one if needed.
379    pub fn callback_holder(&mut self, slot_id: SlotId) -> CallbackHolder {
380        self.record(slot_id)
381            .callback
382            .get_or_insert_with(CallbackHolder::default)
383            .clone()
384    }
385
386    /// The root nodes of `slot_id` when it is the active slot at `position`
387    /// in [`Self::active_slots`] and the next pass would activate it there
388    /// as it is. A measure pass that keeps the slots of the last one checks
389    /// them in that order without starting a pass.
390    pub fn kept_slot_nodes_at(&self, position: usize, slot_id: SlotId) -> Option<&[NodeId]> {
391        if self.active_order.get(position) != Some(&slot_id) {
392            return None;
393        }
394        let record = self.slots.get(&slot_id)?;
395        if record.exact_reactivation || record.reusable_nodes > 0 || record.has_stale_scopes() {
396            return None;
397        }
398        record.nodes.as_deref()
399    }
400
401    /// The active slots, in the order the last pass activated them.
402    pub fn active_slots(&self) -> &[SlotId] {
403        &self.active_order
404    }
405
406    #[doc(hidden)]
407    pub fn activate_current_active_slot(&mut self, slot_id: SlotId) -> Option<&[NodeId]> {
408        if !self.activate_current_active_slot_at_cursor(slot_id) {
409            return None;
410        }
411        self.slot_nodes(slot_id)
412    }
413
414    fn activate_current_active_slot_at_cursor(&mut self, slot_id: SlotId) -> bool {
415        if self.active_order.get(self.current_index) != Some(&slot_id) {
416            return false;
417        }
418        let pass = self.pass;
419        let Some(record) = self.slots.get_mut(&slot_id) else {
420            return false;
421        };
422        if record.active_pass == pass
423            || record.exact_reactivation
424            || record.reusable_nodes > 0
425            || record.nodes.is_none()
426            || record.has_stale_scopes()
427        {
428            return false;
429        }
430        record.live = true;
431        record.active_pass = pass;
432        self.last_slot_reused = Some(true);
433        self.current_index += 1;
434        true
435    }
436
437    /// Whether precomposed nodes are waiting to be consumed for this slot.
438    /// A pending precomposition means the retained mapping is about to be
439    /// superseded, so clean-slot reuse must reject and compose.
440    pub fn has_pending_precompositions(&self, slot_id: SlotId) -> bool {
441        self.precomposed_nodes.contains_key(&slot_id)
442    }
443
444    /// Whether the slot's stored capture key equals `key`. A missing key never
445    /// matches: a slot must compose at least once under the key discipline
446    /// before it may skip.
447    pub fn retained_capture_key_matches<K: PartialEq + 'static>(
448        &self,
449        slot_id: SlotId,
450        key: &K,
451    ) -> bool {
452        self.slots
453            .get(&slot_id)
454            .and_then(|record| record.capture_key.as_ref())
455            .is_some_and(|retained| (retained.eq)(retained.value.as_ref(), key))
456    }
457
458    /// Records the capture key the slot is being composed under.
459    pub fn store_retained_capture_key<K: PartialEq + 'static>(&mut self, slot_id: SlotId, key: K) {
460        self.record(slot_id).capture_key = Some(RetainedCaptureKey {
461            value: Box::new(key),
462            eq: retained_capture_key_eq::<K>,
463        });
464    }
465
466    #[doc(hidden)]
467    pub fn activate_exact_slot(&mut self, slot_id: SlotId) -> Option<ExactSlotActivation<'_>> {
468        let was_recycled = if self.activate_current_active_slot_at_cursor(slot_id) {
469            false
470        } else {
471            self.reactivate_exact_slot(slot_id)?
472        };
473        Some(ExactSlotActivation {
474            nodes: self.slot_nodes(slot_id)?,
475            was_recycled,
476        })
477    }
478
479    /// Activates `slot_id` as it was away from the cursor: active further on,
480    /// or waiting in the reuse pool to be reactivated as it was. Returns
481    /// whether it came from the pool.
482    fn reactivate_exact_slot(&mut self, slot_id: SlotId) -> Option<bool> {
483        let active_position = self
484            .active_order
485            .iter()
486            .position(|candidate| *candidate == slot_id);
487        let record = self.slots.get(&slot_id)?;
488        if active_position.is_none() && !record.exact_reactivation || record.has_invalid_scopes() {
489            return None;
490        }
491        let nodes: SmallVec<[NodeId; 4]> = record.nodes.as_deref()?.iter().copied().collect();
492        if active_position.is_none() {
493            for scope in record.scopes.iter().flatten() {
494                scope.reactivate();
495            }
496            for &node in &nodes {
497                let _ = self.remove_from_reusable_pools(node);
498            }
499        }
500        self.mark_slot_active(slot_id, true);
501        Self::consume_precomposed_nodes(
502            &mut self.precomposed_nodes,
503            &mut self.precomposed_count,
504            slot_id,
505            &nodes,
506        );
507        Some(active_position.is_none())
508    }
509
510    /// Records that the nodes in `node_ids` are currently rendering the provided
511    /// `slot_id`.
512    pub fn register_active(
513        &mut self,
514        slot_id: SlotId,
515        node_ids: &[NodeId],
516        scopes: &[RecomposeScope],
517    ) {
518        for scope in scopes {
519            scope.reactivate();
520        }
521        let record = self.slots.entry(slot_id).or_default();
522        let was_reused = record.nodes.is_some();
523        let nodes = record.nodes.get_or_insert_default();
524        nodes.clear();
525        nodes.extend_from_slice(node_ids);
526        let held_scopes = record.scopes.get_or_insert_default();
527        held_scopes.clear();
528        held_scopes.extend_from_slice(scopes);
529        for &node in node_ids {
530            self.node_to_slot.insert(node, slot_id);
531        }
532        Self::consume_precomposed_nodes(
533            &mut self.precomposed_nodes,
534            &mut self.precomposed_count,
535            slot_id,
536            node_ids,
537        );
538        self.mark_slot_active(slot_id, was_reused);
539    }
540
541    fn mark_slot_active(&mut self, slot_id: SlotId, was_reused: bool) {
542        self.last_slot_reused = Some(was_reused);
543        let pass = self.pass;
544        let record = self.record(slot_id);
545        record.live = true;
546        record.active_pass = pass;
547        record.exact_reactivation = false;
548
549        if let Some(position) = self.active_order.iter().position(|slot| *slot == slot_id) {
550            if position < self.current_index {
551                return;
552            }
553            self.active_order.remove(position);
554        }
555        let insert_at = self.current_index.min(self.active_order.len());
556        self.active_order.insert(insert_at, slot_id);
557        self.current_index += 1;
558    }
559
560    fn consume_precomposed_nodes(
561        precomposed_nodes: &mut HashMap<SlotId, Vec<NodeId>>,
562        precomposed_count: &mut usize,
563        slot_id: SlotId,
564        node_ids: &[NodeId],
565    ) {
566        if let Some(nodes) = precomposed_nodes.get_mut(&slot_id) {
567            let before_len = nodes.len();
568            nodes.retain(|node| !node_ids.contains(node));
569            let removed = before_len - nodes.len();
570            *precomposed_count = precomposed_count.saturating_sub(removed);
571            if nodes.is_empty() {
572                precomposed_nodes.remove(&slot_id);
573            }
574        }
575    }
576
577    /// Stores a precomposed node for the provided slot. Precomposed nodes stay
578    /// detached from the tree until they are activated by `register_active`.
579    pub fn register_precomposed(&mut self, slot_id: SlotId, node_id: NodeId) {
580        self.precomposed_nodes
581            .entry(slot_id)
582            .or_default()
583            .push(node_id);
584        self.precomposed_count += 1;
585    }
586
587    fn decrement_reusable_slot(&mut self, slot_id: SlotId) {
588        if let Some(record) = self.slots.get_mut(&slot_id) {
589            record.reusable_nodes = record.reusable_nodes.saturating_sub(1);
590        }
591        self.reusable_count = self.reusable_count.saturating_sub(1);
592    }
593
594    /// Takes `node` out of the slot it rendered, and returns that slot. A
595    /// slot left with no nodes forgets its scopes too.
596    fn remove_node_from_slot(&mut self, node: NodeId) -> Option<SlotId> {
597        let slot = self.node_to_slot.remove(&node)?;
598        if let Some(record) = self.slots.get_mut(&slot)
599            && let Some(nodes) = record.nodes.as_mut()
600        {
601            if let Some(index) = nodes.iter().position(|candidate| *candidate == node) {
602                nodes.remove(index);
603            }
604            if nodes.is_empty() {
605                record.nodes = None;
606                record.scopes = None;
607            }
608        }
609        Some(slot)
610    }
611
612    /// Whether `slot_id` is composed and active: the last pass kept it, or
613    /// the current one did.
614    pub fn slot_is_active(&self, slot_id: SlotId) -> bool {
615        self.slots.get(&slot_id).is_some_and(|record| record.live)
616    }
617
618    /// Whether `slot_id` still has a composition here: active, or kept in the
619    /// reuse pool where it can be reactivated. A slot that is neither has
620    /// been disposed, and nothing measured for it can be used again.
621    pub fn slot_is_retained(&self, slot_id: SlotId) -> bool {
622        self.slots
623            .get(&slot_id)
624            .is_some_and(|record| record.live || record.reusable_nodes > 0)
625    }
626
627    fn prune_slot_if_unused(&mut self, slot_id: SlotId) -> Option<Rc<SlotsHost>> {
628        if self.slot_is_retained(slot_id) {
629            return None;
630        }
631
632        debug_assert!(
633            self.slot_nodes(slot_id).is_none(),
634            "inactive slot {slot_id:?} still has mapped nodes",
635        );
636
637        let host = self
638            .slots
639            .remove(&slot_id)
640            .and_then(|record| record.composition);
641        if let Some(nodes) = self.precomposed_nodes.remove(&slot_id) {
642            self.precomposed_count = self.precomposed_count.saturating_sub(nodes.len());
643        }
644        host
645    }
646
647    /// Returns the node that previously rendered this slot, if it is still
648    /// considered reusable, and whether it was REBOUND — taken from another
649    /// slot, so its retained subtree is about to show different content. An
650    /// exact-slot reactivation hands the same item its own subtree back and
651    /// is not a rebinding.
652    ///
653    /// Lookup order:
654    /// 1. Exact slot match in the appropriate pool (not a rebinding)
655    /// 2. Any policy-compatible node from the same content-type pool
656    /// 3. Fallback to untyped pool with policy compatibility check
657    pub fn take_node_from_reusables(&mut self, slot_id: SlotId) -> Option<(NodeId, bool)> {
658        if let Some(node_id) = self
659            .slot_nodes(slot_id)
660            .and_then(|nodes| nodes.first().copied())
661        {
662            let _ = self.remove_from_reusable_pools(node_id);
663            self.clear_exact_reactivation(slot_id);
664            return Some((node_id, false));
665        }
666
667        let requested = self.typed_slot(slot_id);
668        let (pool, slots, policy) = (&mut self.pool, &self.slots, &self.policy);
669        let accepts = |existing| reusable_for(slots, policy.as_ref(), existing, requested);
670        // A typed slot falls back to the untyped queue.
671        let (old_slot, node_id) = requested
672            .content_type
673            .and_then(|content_type| pool.take(Some(content_type), accepts))
674            .or_else(|| pool.take(None, accepts))?;
675        self.decrement_reusable_slot(old_slot);
676        self.move_node_to_slot(node_id, old_slot, slot_id);
677        Some((node_id, true))
678    }
679
680    fn remove_from_reusable_pools(&mut self, node_id: NodeId) -> Option<SlotId> {
681        let slot = self.pool.remove_node(node_id)?;
682        self.decrement_reusable_slot(slot);
683        Some(slot)
684    }
685
686    fn move_node_to_slot(&mut self, node_id: NodeId, old_slot: SlotId, new_slot: SlotId) {
687        if old_slot == new_slot {
688            return;
689        }
690
691        self.remove_node_from_slot(node_id);
692        let (composition, callback) = match self.slots.get_mut(&old_slot) {
693            Some(old) => {
694                old.exact_reactivation = false;
695                old.content_type = None;
696                old.capture_key = None;
697                old.composed_generation = None;
698                (old.composition.take(), old.callback.take())
699            }
700            None => (None, None),
701        };
702        if self.slots.get(&old_slot).is_some_and(SlotRecord::is_empty) {
703            self.slots.remove(&old_slot);
704        }
705        self.node_to_slot.insert(node_id, new_slot);
706        let new = self.record(new_slot);
707        new.nodes.get_or_insert_default().push(node_id);
708        new.capture_key = None;
709        new.composed_generation = None;
710        if composition.is_some() {
711            new.composition = composition;
712        }
713        if callback.is_some() {
714            new.callback = callback;
715        }
716        if let Some(nodes) = self.precomposed_nodes.get_mut(&old_slot) {
717            let before_len = nodes.len();
718            nodes.retain(|candidate| *candidate != node_id);
719            let removed = before_len - nodes.len();
720            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
721            if nodes.is_empty() {
722                self.precomposed_nodes.remove(&old_slot);
723            }
724        }
725    }
726
727    /// Moves active slots starting from `start_index` to the reusable bucket.
728    /// Returns the list of node ids that were DISPOSED (not just moved to reusable).
729    /// Nodes past the pool's limits are disposed instead of cached.
730    pub fn dispose_or_reuse_starting_from_index(
731        &mut self,
732        start_index: usize,
733    ) -> SubcomposeDisposal {
734        if start_index >= self.active_order.len() {
735            return SubcomposeDisposal::default();
736        }
737
738        let retain = self
739            .policy
740            .get_slots_to_retain(&self.active_order[start_index..]);
741        let mut retained = Vec::new();
742        while self.active_order.len() > start_index {
743            let Some(slot) = self.active_order.pop() else {
744                break;
745            };
746            if retain.contains(&slot) {
747                retained.push(slot);
748                continue;
749            }
750            self.move_slot_to_reusable(slot, false);
751        }
752        retained.reverse();
753        self.active_order.extend(retained);
754
755        self.enforce_reusable_pool_limits()
756    }
757
758    fn move_slot_to_reusable(&mut self, slot: SlotId, allow_exact_reactivation: bool) {
759        let record = self.slots.entry(slot).or_default();
760        record.live = false;
761        record.active_pass = 0;
762        for scope in record.scopes.iter().flatten() {
763            scope.deactivate();
764        }
765        let forgot_effects = record
766            .composition
767            .as_ref()
768            .is_some_and(|host| host.forget_effects());
769        record.exact_reactivation = allow_exact_reactivation && !forgot_effects;
770
771        let content_type = record.content_type;
772        let nodes = record.nodes.as_deref().unwrap_or_default();
773        record.reusable_nodes += nodes.len();
774        self.reusable_count += nodes.len();
775        self.pool.push(content_type, slot, nodes.iter().copied());
776    }
777
778    fn enforce_reusable_pool_limits(&mut self) -> SubcomposeDisposal {
779        let mut disposed = SubcomposeDisposal::default();
780        for (slot, node_id) in self.pool.take_excess() {
781            self.dispose_reusable_node(slot, node_id, &mut disposed);
782        }
783        disposed
784    }
785
786    /// Disposes `node_id`, which waited in a reuse pool for `slot`.
787    fn dispose_reusable_node(
788        &mut self,
789        slot: SlotId,
790        node_id: NodeId,
791        disposed: &mut SubcomposeDisposal,
792    ) {
793        self.decrement_reusable_slot(slot);
794        self.remove_node_from_slot(node_id);
795        self.clear_exact_reactivation(slot);
796        disposed.slot_hosts.extend(self.prune_slot_if_unused(slot));
797        disposed.nodes.push(node_id);
798    }
799
800    /// Returns a snapshot of currently reusable nodes.
801    pub fn reusable(&self) -> Vec<NodeId> {
802        self.pool.nodes().collect()
803    }
804
805    /// Returns the number of slots currently active (in use during this pass).
806    ///
807    /// This reflects the slots that were activated via `register_active()` during
808    /// the current measurement pass.
809    pub fn active_slots_count(&self) -> usize {
810        self.active_order.len()
811    }
812
813    /// Returns the number of reusable slots in the pool.
814    ///
815    /// These are slots that were previously active but are now available for reuse
816    /// by compatible content types.
817    pub fn reusable_slots_count(&self) -> usize {
818        self.reusable_count
819    }
820
821    /// Invalidates all tracked subcomposition scopes.
822    ///
823    /// Hosts should call this when parent-captured inputs change without directly invalidating
824    /// the child scopes themselves. The next subcompose pass will then re-run active slot
825    /// content instead of skipping with stale captures.
826    /// Invalidating scopes alone is not a reliable "recompose on next
827    /// measure" signal: the recomposer drains invalid scopes before measure
828    /// runs, re-executing the RETAINED slot callbacks — parent-captured
829    /// values baked into a replaced closure never flow in, yet the scopes
830    /// come back valid. The generation bump is the unlaunderable half: only
831    /// a measure-time compose of the slot brings it current, so clean-slot
832    /// reuse stays blocked until the new content actually landed.
833    pub fn invalidate_scopes(&self) {
834        for scope in self
835            .slots
836            .values()
837            .flat_map(|record| record.scopes.iter().flatten())
838        {
839            scope.invalidate();
840        }
841        self.content_generation
842            .set(self.content_generation.get() + 1);
843    }
844
845    /// Records that a static composition local the slots compose under
846    /// changed: every slot composed before must compose again, and run
847    /// every body it holds, before it may be reused.
848    pub fn invalidate_locals(&self) {
849        self.bump_content_generation();
850        self.locals_generation.set(self.content_generation.get());
851    }
852
853    /// Whether the slot composed before a static composition local it reads
854    /// from changed; see [`Self::invalidate_locals`]. A composition another
855    /// slot left counts as fresh: its scopes come back inactive, and a group
856    /// entered with an inactive scope composes again anyway.
857    pub fn slot_locals_stale(&self, slot_id: SlotId) -> bool {
858        let changed_at = self.locals_generation.get();
859        self.slots
860            .get(&slot_id)
861            .and_then(|record| record.composed_generation)
862            .is_some_and(|(generation, _)| generation < changed_at)
863    }
864
865    /// Advances the content generation without invalidating scopes: every
866    /// slot must re-compose once before clean-slot reuse may skip it again.
867    /// Called when the owning node leaves its parent — a retained subtree
868    /// that comes back from the reuse pool restarts its effects only if its
869    /// slots actually recompose, and scope flags carry no durable trace of
870    /// the detach (deactivation walks stop at nested slot-host boundaries).
871    pub fn bump_content_generation(&self) {
872        self.content_generation
873            .set(self.content_generation.get() + 1);
874    }
875
876    /// Whether the slot last composed under the current content generation
877    /// and owner-chain deactivation epoch. False after any
878    /// [`Self::invalidate_scopes`], and false after any composition up the
879    /// owner chain was deactivated, until a measure-time compose of this
880    /// slot runs.
881    pub fn slot_content_generation_current(&self, slot_id: SlotId, owner_epoch: u64) -> bool {
882        self.slots
883            .get(&slot_id)
884            .and_then(|record| record.composed_generation)
885            == Some((self.content_generation.get(), owner_epoch))
886    }
887
888    /// Records that the slot just composed under the current generation and
889    /// the given owner-chain deactivation epoch.
890    pub fn mark_slot_composed_current(&mut self, slot_id: SlotId, owner_epoch: u64) {
891        let generation = self.content_generation.get();
892        self.record(slot_id).composed_generation = Some((generation, owner_epoch));
893    }
894
895    /// Returns whether the last slot registered via [`Self::register_active`] was reused.
896    ///
897    /// Returns `Some(true)` if the slot already existed (was reused from pool or
898    /// was recomposed), `Some(false)` if it was newly created, or `None` if no
899    /// slot has been registered yet this pass.
900    ///
901    /// This is useful for tracking composition statistics in lazy layouts.
902    pub fn was_last_slot_reused(&self) -> Option<bool> {
903        self.last_slot_reused
904    }
905
906    #[doc(hidden)]
907    pub fn debug_scope_ids_by_slot(&self) -> Vec<(u64, Vec<usize>)> {
908        self.slots
909            .iter()
910            .filter_map(|(slot, record)| {
911                let scopes = record.scopes.as_ref()?;
912                Some((slot.raw(), scopes.iter().map(RecomposeScope::id).collect()))
913            })
914            .collect()
915    }
916
917    #[doc(hidden)]
918    pub fn debug_slot_table_for_slot(&self, slot_id: SlotId) -> Option<Vec<crate::SlotDebugEntry>> {
919        let slots = self.slots.get(&slot_id)?.composition.as_ref()?;
920        Some(slots.borrow().debug_dump_slot_entries())
921    }
922
923    #[doc(hidden)]
924    pub fn debug_slot_table_groups_for_slot(&self, slot_id: SlotId) -> Option<Vec<DebugSlotGroup>> {
925        let slots = self.slots.get(&slot_id)?.composition.as_ref()?;
926        Some(slots.borrow().debug_dump_groups())
927    }
928
929    /// Returns a snapshot of precomposed nodes.
930    pub fn precomposed(&self) -> &HashMap<SlotId, Vec<NodeId>> {
931        &self.precomposed_nodes
932    }
933
934    /// Removes any precomposed nodes whose slots were not activated during the
935    /// current pass and returns their identifiers for disposal.
936    pub fn drain_inactive_precomposed(&mut self) -> SubcomposeDisposal {
937        let mut disposed = SubcomposeDisposal::default();
938        let mut empty_slots = Vec::new();
939        for (slot, nodes) in &self.precomposed_nodes {
940            if !self.slot_active_in_pass(*slot) {
941                disposed.nodes.extend(nodes.iter().copied());
942                empty_slots.push(*slot);
943            }
944        }
945        for slot in empty_slots {
946            self.precomposed_nodes.remove(&slot);
947            disposed.slot_hosts.extend(self.prune_slot_if_unused(slot));
948        }
949        self.precomposed_count = self.precomposed_count.saturating_sub(disposed.nodes.len());
950        disposed
951    }
952}
953
954/// Whether the node pooled for `existing` may compose `requested`: the slot
955/// waits to be reactivated as it was, and `policy` decides the rest.
956fn reusable_for(
957    slots: &HashMap<SlotId, SlotRecord>,
958    policy: &dyn SlotReusePolicy,
959    existing: SlotId,
960    requested: TypedSlot,
961) -> bool {
962    let record = slots.get(&existing);
963    !record.is_some_and(|record| record.exact_reactivation)
964        && policy.are_compatible(
965            TypedSlot {
966                id: existing,
967                content_type: record.and_then(|record| record.content_type),
968            },
969            requested,
970        )
971}
972
973#[cfg(test)]
974#[path = "tests/subcompose_tests.rs"]
975mod tests;