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