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 crate::collections::map::HashMap;
10use crate::collections::map::HashSet;
11use std::collections::VecDeque;
12use std::fmt;
13use std::rc::Rc;
14
15use crate::{CallbackHolder, NodeId, RecomposeScope, SlotTable, SlotsHost};
16
17pub type DebugSlotGroup = (usize, crate::Key, Option<usize>, usize);
18
19/// Identifier for a subcomposed slot.
20///
21/// This mirrors the `slotId` concept in Jetpack Compose where callers provide
22/// stable identifiers for reusable children during measure-time composition.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct SlotId(pub u64);
25
26impl SlotId {
27    #[inline]
28    pub fn new(raw: u64) -> Self {
29        Self(raw)
30    }
31
32    #[inline]
33    pub fn raw(self) -> u64 {
34        self.0
35    }
36}
37
38/// Policy that decides which previously composed slots should be retained for
39/// potential reuse during the next subcompose pass.
40///
41/// Note: This trait does NOT require Send + Sync because the compose runtime
42/// is single-threaded (uses Rc/RefCell throughout).
43pub trait SlotReusePolicy: 'static {
44    /// Returns the subset of slots that should be retained for reuse after the
45    /// current measurement pass. Slots that are not part of the returned set
46    /// will be disposed.
47    fn get_slots_to_retain(&self, active: &[SlotId]) -> HashSet<SlotId>;
48
49    /// Determines whether a node that previously rendered the slot `existing`
50    /// can be reused when the caller requests `requested`.
51    ///
52    /// Implementations should document what constitutes compatibility (for
53    /// example, identical slot identifiers, matching layout classes, or node
54    /// types). Returning `true` allows [`SubcomposeState`] to migrate the node
55    /// across slots instead of disposing it.
56    fn are_compatible(&self, existing: SlotId, requested: SlotId) -> bool;
57
58    /// Registers the content type for a slot.
59    ///
60    /// Policies that support content-type-based reuse (like [`ContentTypeReusePolicy`])
61    /// should override this to record the type. The default implementation is a no-op.
62    ///
63    /// Call this before subcomposing an item to enable content-type-aware slot reuse.
64    fn register_content_type(&self, _slot_id: SlotId, _content_type: u64) {
65        // Default: no-op for policies that don't care about content types
66    }
67
68    /// Removes the content type for a slot (e.g., when transitioning to None).
69    ///
70    /// Policies that track content types should override this to clean up.
71    /// The default implementation is a no-op.
72    fn remove_content_type(&self, _slot_id: SlotId) {
73        // Default: no-op for policies that don't track content types
74    }
75
76    /// Prunes slot data for slots not in the active set.
77    ///
78    /// Called during [`SubcomposeState::prune_inactive_slots`] to allow policies
79    /// to clean up any internal state for slots that are no longer needed.
80    /// The default implementation is a no-op.
81    fn prune_slots(&self, _keep_slots: &HashSet<SlotId>) {
82        // Default: no-op for policies without per-slot state
83    }
84}
85
86/// Default reuse policy that mirrors Jetpack Compose behaviour: dispose
87/// everything from the tail so that the next measurement can decide which
88/// content to keep alive. Compatibility defaults to exact slot matches.
89#[derive(Debug, Default)]
90pub struct DefaultSlotReusePolicy;
91
92impl SlotReusePolicy for DefaultSlotReusePolicy {
93    fn get_slots_to_retain(&self, active: &[SlotId]) -> HashSet<SlotId> {
94        let _ = active;
95        HashSet::default()
96    }
97
98    fn are_compatible(&self, existing: SlotId, requested: SlotId) -> bool {
99        existing == requested
100    }
101}
102
103/// Reuse policy that allows cross-slot reuse when content types match.
104///
105/// This policy enables efficient recycling of layout nodes across different
106/// slot IDs when they share the same content type (e.g., list items with
107/// similar structure but different data).
108///
109/// # Example
110///
111/// ```rust,ignore
112/// use cranpose_core::{ContentTypeReusePolicy, SubcomposeState, SlotId};
113///
114/// let mut policy = ContentTypeReusePolicy::new();
115///
116/// // Register content types for slots
117/// policy.set_content_type(SlotId::new(0), 1); // Header type
118/// policy.set_content_type(SlotId::new(1), 2); // Item type
119/// policy.set_content_type(SlotId::new(2), 2); // Item type (same as slot 1)
120///
121/// // Slot 1 can reuse slot 2's node since they share content type 2
122/// assert!(policy.are_compatible(SlotId::new(2), SlotId::new(1)));
123/// ```
124pub struct ContentTypeReusePolicy {
125    /// Maps slot ID to content type.
126    slot_types: std::cell::RefCell<HashMap<SlotId, u64>>,
127}
128
129impl std::fmt::Debug for ContentTypeReusePolicy {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        let types = self.slot_types.borrow();
132        f.debug_struct("ContentTypeReusePolicy")
133            .field("slot_types", &*types)
134            .finish()
135    }
136}
137
138impl Default for ContentTypeReusePolicy {
139    fn default() -> Self {
140        Self::new()
141    }
142}
143
144impl ContentTypeReusePolicy {
145    /// Creates a new content-type-aware reuse policy.
146    pub fn new() -> Self {
147        Self {
148            slot_types: std::cell::RefCell::new(HashMap::default()),
149        }
150    }
151
152    /// Registers the content type for a slot.
153    ///
154    /// Call this when subcomposing an item with a known content type.
155    pub fn set_content_type(&self, slot: SlotId, content_type: u64) {
156        self.slot_types.borrow_mut().insert(slot, content_type);
157    }
158
159    /// Removes the content type for a slot (e.g., when disposed).
160    pub fn remove_content_type(&self, slot: SlotId) {
161        self.slot_types.borrow_mut().remove(&slot);
162    }
163
164    /// Clears all registered content types.
165    pub fn clear(&self) {
166        self.slot_types.borrow_mut().clear();
167    }
168
169    /// Returns the content type for a slot, if registered.
170    pub fn get_content_type(&self, slot: SlotId) -> Option<u64> {
171        self.slot_types.borrow().get(&slot).copied()
172    }
173}
174
175impl SlotReusePolicy for ContentTypeReusePolicy {
176    fn get_slots_to_retain(&self, active: &[SlotId]) -> HashSet<SlotId> {
177        let _ = active;
178        // Don't retain any - let SubcomposeState manage reusable pool
179        HashSet::default()
180    }
181
182    fn are_compatible(&self, existing: SlotId, requested: SlotId) -> bool {
183        // Exact match always wins
184        if existing == requested {
185            return true;
186        }
187
188        // Check content type compatibility
189        let types = self.slot_types.borrow();
190        match (types.get(&existing), types.get(&requested)) {
191            (Some(existing_type), Some(requested_type)) => existing_type == requested_type,
192            // If either slot has no type, fall back to exact match only
193            _ => false,
194        }
195    }
196
197    fn register_content_type(&self, slot_id: SlotId, content_type: u64) {
198        self.set_content_type(slot_id, content_type);
199    }
200
201    fn remove_content_type(&self, slot_id: SlotId) {
202        ContentTypeReusePolicy::remove_content_type(self, slot_id);
203    }
204
205    fn prune_slots(&self, keep_slots: &HashSet<SlotId>) {
206        self.slot_types
207            .borrow_mut()
208            .retain(|slot, _| keep_slots.contains(slot));
209    }
210}
211
212#[derive(Default, Clone)]
213
214struct NodeSlotMapping {
215    slot_to_nodes: HashMap<SlotId, Vec<NodeId>>,
216    node_to_slot: HashMap<NodeId, SlotId>,
217    slot_to_scopes: HashMap<SlotId, Vec<RecomposeScope>>,
218}
219
220impl fmt::Debug for NodeSlotMapping {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        f.debug_struct("NodeSlotMapping")
223            .field("slot_to_nodes", &self.slot_to_nodes)
224            .field("node_to_slot", &self.node_to_slot)
225            .finish()
226    }
227}
228
229impl NodeSlotMapping {
230    fn set_nodes(&mut self, slot: SlotId, nodes: &[NodeId]) {
231        self.slot_to_nodes.insert(slot, nodes.to_vec());
232        for node in nodes {
233            self.node_to_slot.insert(*node, slot);
234        }
235    }
236
237    fn set_scopes(&mut self, slot: SlotId, scopes: &[RecomposeScope]) {
238        self.slot_to_scopes.insert(slot, scopes.to_vec());
239    }
240
241    fn add_node(&mut self, slot: SlotId, node: NodeId) {
242        self.slot_to_nodes.entry(slot).or_default().push(node);
243        self.node_to_slot.insert(node, slot);
244    }
245
246    fn remove_by_node(&mut self, node: &NodeId) -> Option<SlotId> {
247        if let Some(slot) = self.node_to_slot.remove(node) {
248            if let Some(nodes) = self.slot_to_nodes.get_mut(&slot) {
249                if let Some(index) = nodes.iter().position(|candidate| candidate == node) {
250                    nodes.remove(index);
251                }
252                if nodes.is_empty() {
253                    self.slot_to_nodes.remove(&slot);
254                    // Also clean up slot_to_scopes when slot becomes empty
255                    self.slot_to_scopes.remove(&slot);
256                }
257            }
258            Some(slot)
259        } else {
260            None
261        }
262    }
263
264    fn get_nodes(&self, slot: &SlotId) -> Option<&[NodeId]> {
265        self.slot_to_nodes.get(slot).map(|nodes| nodes.as_slice())
266    }
267
268    fn get_slot(&self, node: &NodeId) -> Option<SlotId> {
269        self.node_to_slot.get(node).copied()
270    }
271
272    fn deactivate_slot(&self, slot: SlotId) {
273        if let Some(scopes) = self.slot_to_scopes.get(&slot) {
274            for scope in scopes {
275                scope.deactivate();
276            }
277        }
278    }
279
280    fn invalidate_scopes(&self) {
281        for scopes in self.slot_to_scopes.values() {
282            for scope in scopes {
283                scope.invalidate();
284            }
285        }
286    }
287
288    fn retain_slots(&mut self, active: &HashSet<SlotId>) -> Vec<NodeId> {
289        let mut removed_nodes = Vec::new();
290        self.slot_to_nodes.retain(|slot, nodes| {
291            if active.contains(slot) {
292                true
293            } else {
294                removed_nodes.extend(nodes.iter().copied());
295                false
296            }
297        });
298        self.slot_to_scopes.retain(|slot, _| active.contains(slot));
299        for node in &removed_nodes {
300            self.node_to_slot.remove(node);
301        }
302        removed_nodes
303    }
304}
305
306/// Tracks the state of nodes produced by subcomposition, enabling reuse between
307/// measurement passes.
308pub struct SubcomposeState {
309    mapping: NodeSlotMapping,
310    active_order: Vec<SlotId>,
311    /// Per-content-type reusable node pools for O(1) compatible node lookup.
312    /// Key is content type, value is a deque of (SlotId, NodeId) pairs.
313    /// Nodes without content type go to `reusable_nodes_untyped`.
314    reusable_by_type: HashMap<u64, VecDeque<(SlotId, NodeId)>>,
315    /// Reusable nodes without a content type (fallback pool).
316    reusable_nodes_untyped: VecDeque<(SlotId, NodeId)>,
317    /// Maps slot to its content type for efficient lookup during reuse.
318    slot_content_types: HashMap<SlotId, u64>,
319    precomposed_nodes: HashMap<SlotId, Vec<NodeId>>,
320    policy: Box<dyn SlotReusePolicy>,
321    pub(crate) current_index: usize,
322    pub(crate) reusable_count: usize,
323    pub(crate) precomposed_count: usize,
324    /// Per-slot SlotsHost for isolated compositions.
325    /// Each SlotId gets its own slot table, avoiding cursor-based conflicts
326    /// when items are subcomposed in different orders.
327    slot_compositions: HashMap<SlotId, Rc<SlotsHost>>,
328    /// Latest slot root callbacks keyed by slot id.
329    slot_callbacks: HashMap<SlotId, CallbackHolder>,
330    /// Maximum number of reusable slots to keep cached per content type.
331    max_reusable_per_type: usize,
332    /// Maximum number of reusable slots for the untyped pool.
333    max_reusable_untyped: usize,
334    /// Whether the last slot registered via register_active was reused.
335    /// Set during register_active, read via was_last_slot_reused().
336    last_slot_reused: Option<bool>,
337}
338
339impl fmt::Debug for SubcomposeState {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        f.debug_struct("SubcomposeState")
342            .field("mapping", &self.mapping)
343            .field("active_order", &self.active_order)
344            .field("reusable_by_type_count", &self.reusable_by_type.len())
345            .field("reusable_untyped_count", &self.reusable_nodes_untyped.len())
346            .field("precomposed_nodes", &self.precomposed_nodes)
347            .field("current_index", &self.current_index)
348            .field("reusable_count", &self.reusable_count)
349            .field("precomposed_count", &self.precomposed_count)
350            .field("slot_compositions_count", &self.slot_compositions.len())
351            .finish()
352    }
353}
354
355impl Default for SubcomposeState {
356    fn default() -> Self {
357        Self::new(Box::new(DefaultSlotReusePolicy))
358    }
359}
360
361/// Default maximum reusable slots to cache per content type.
362/// With multiple content types, total reusable = this * number_of_types.
363const DEFAULT_MAX_REUSABLE_PER_TYPE: usize = 5;
364
365/// Default maximum reusable slots for the untyped pool.
366/// This is higher than per-type since all items without content types share this pool.
367/// Matches RecyclerView's default cache size.
368const DEFAULT_MAX_REUSABLE_UNTYPED: usize = 10;
369
370impl SubcomposeState {
371    /// Creates a new [`SubcomposeState`] using the supplied reuse policy.
372    pub fn new(policy: Box<dyn SlotReusePolicy>) -> Self {
373        Self {
374            mapping: NodeSlotMapping::default(),
375            active_order: Vec::new(),
376            reusable_by_type: HashMap::default(),
377            reusable_nodes_untyped: VecDeque::new(),
378            slot_content_types: HashMap::default(),
379            precomposed_nodes: HashMap::default(),
380            policy,
381            current_index: 0,
382            reusable_count: 0,
383            precomposed_count: 0,
384            slot_compositions: HashMap::default(),
385            slot_callbacks: HashMap::default(),
386            max_reusable_per_type: DEFAULT_MAX_REUSABLE_PER_TYPE,
387            max_reusable_untyped: DEFAULT_MAX_REUSABLE_UNTYPED,
388            last_slot_reused: None,
389        }
390    }
391
392    /// Sets the policy used for future reuse decisions.
393    pub fn set_policy(&mut self, policy: Box<dyn SlotReusePolicy>) {
394        self.policy = policy;
395    }
396
397    /// Registers a content type for a slot.
398    ///
399    /// Stores the content type locally for efficient pool-based reuse lookup,
400    /// and also delegates to the policy for compatibility checking.
401    ///
402    /// Call this before subcomposing an item to enable content-type-aware slot reuse.
403    pub fn register_content_type(&mut self, slot_id: SlotId, content_type: u64) {
404        self.slot_content_types.insert(slot_id, content_type);
405        self.policy.register_content_type(slot_id, content_type);
406    }
407
408    /// Updates the content type for a slot, handling Some→None transitions.
409    ///
410    /// If `content_type` is `Some(type)`, registers the type for the slot.
411    /// If `content_type` is `None`, removes any previously registered type.
412    /// This ensures stale types don't drive incorrect reuse.
413    pub fn update_content_type(&mut self, slot_id: SlotId, content_type: Option<u64>) {
414        match content_type {
415            Some(ct) => self.register_content_type(slot_id, ct),
416            None => {
417                self.slot_content_types.remove(&slot_id);
418                self.policy.remove_content_type(slot_id);
419            }
420        }
421    }
422
423    /// Returns the content type for a slot, if registered.
424    pub fn get_content_type(&self, slot_id: SlotId) -> Option<u64> {
425        self.slot_content_types.get(&slot_id).copied()
426    }
427
428    /// Starts a new subcompose pass.
429    ///
430    /// Call this before subcomposing the current frame so the state can
431    /// track which slots are active and dispose the inactive ones later.
432    pub fn begin_pass(&mut self) {
433        self.current_index = 0;
434    }
435
436    /// Finishes a subcompose pass, disposing slots that were not used.
437    pub fn finish_pass(&mut self) -> Vec<NodeId> {
438        let disposed = self.dispose_or_reuse_starting_from_index(self.current_index);
439        self.prune_inactive_slots();
440        disposed
441    }
442
443    /// Returns the SlotsHost for the given slot ID, creating a new one if it doesn't exist.
444    /// Each slot gets its own isolated slot table, avoiding cursor-based conflicts when
445    /// items are subcomposed in different orders.
446    pub fn get_or_create_slots(&mut self, slot_id: SlotId) -> Rc<SlotsHost> {
447        Rc::clone(
448            self.slot_compositions
449                .entry(slot_id)
450                .or_insert_with(|| Rc::new(SlotsHost::new(SlotTable::new()))),
451        )
452    }
453
454    /// Returns the latest callback holder for the given slot, creating one if needed.
455    pub fn callback_holder(&mut self, slot_id: SlotId) -> CallbackHolder {
456        self.slot_callbacks.entry(slot_id).or_default().clone()
457    }
458
459    /// Records that the nodes in `node_ids` are currently rendering the provided
460    /// `slot_id`.
461    pub fn register_active(
462        &mut self,
463        slot_id: SlotId,
464        node_ids: &[NodeId],
465        scopes: &[RecomposeScope],
466    ) {
467        // Track whether this slot was reused (had existing nodes before this call)
468        let was_reused =
469            self.mapping.get_nodes(&slot_id).is_some() || self.active_order.contains(&slot_id);
470        self.last_slot_reused = Some(was_reused);
471
472        if let Some(position) = self.active_order.iter().position(|slot| *slot == slot_id) {
473            if position < self.current_index {
474                for scope in scopes {
475                    scope.reactivate();
476                }
477                self.mapping.set_nodes(slot_id, node_ids);
478                self.mapping.set_scopes(slot_id, scopes);
479                if let Some(nodes) = self.precomposed_nodes.get_mut(&slot_id) {
480                    let before_len = nodes.len();
481                    nodes.retain(|node| !node_ids.contains(node));
482                    let removed = before_len - nodes.len();
483                    self.precomposed_count = self.precomposed_count.saturating_sub(removed);
484                    if nodes.is_empty() {
485                        self.precomposed_nodes.remove(&slot_id);
486                    }
487                }
488                return;
489            }
490            self.active_order.remove(position);
491        }
492        for scope in scopes {
493            scope.reactivate();
494        }
495        self.mapping.set_nodes(slot_id, node_ids);
496        self.mapping.set_scopes(slot_id, scopes);
497        if let Some(nodes) = self.precomposed_nodes.get_mut(&slot_id) {
498            let before_len = nodes.len();
499            nodes.retain(|node| !node_ids.contains(node));
500            let removed = before_len - nodes.len();
501            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
502            if nodes.is_empty() {
503                self.precomposed_nodes.remove(&slot_id);
504            }
505        }
506        let insert_at = self.current_index.min(self.active_order.len());
507        self.active_order.insert(insert_at, slot_id);
508        self.current_index += 1;
509    }
510
511    /// Stores a precomposed node for the provided slot. Precomposed nodes stay
512    /// detached from the tree until they are activated by `register_active`.
513    pub fn register_precomposed(&mut self, slot_id: SlotId, node_id: NodeId) {
514        self.precomposed_nodes
515            .entry(slot_id)
516            .or_default()
517            .push(node_id);
518        self.precomposed_count += 1;
519    }
520
521    /// Returns the node that previously rendered this slot, if it is still
522    /// considered reusable. Uses O(1) content-type based lookup when available.
523    ///
524    /// Lookup order:
525    /// 1. Exact slot match in the appropriate pool
526    /// 2. Any compatible node from the same content-type pool (O(1) pop)
527    /// 3. Fallback to untyped pool with policy compatibility check
528    pub fn take_node_from_reusables(&mut self, slot_id: SlotId) -> Option<NodeId> {
529        // First, try to find an exact slot match in mapping
530        // CRITICAL FIX: Return active nodes directly without requiring them to be in
531        // reusable pools. During multi-pass measurement, nodes registered via register_active
532        // are in the mapping but NOT in reusable pools (pools only get populated in finish_pass).
533        // Without this fix, new virtual nodes are created each measure pass, losing children.
534        if let Some(nodes) = self.mapping.get_nodes(&slot_id) {
535            let first_node = nodes.first().copied();
536            if let Some(node_id) = first_node {
537                // Try to remove from reusable pools if present (for nodes that were deactivated)
538                let _ = self.remove_from_reusable_pools(node_id);
539                self.update_reusable_count();
540                return Some(node_id);
541            }
542        }
543
544        // Get the content type for the requested slot
545        let content_type = self.slot_content_types.get(&slot_id).copied();
546
547        // Try to get a node from the same content-type pool (O(1))
548        if let Some(ct) = content_type {
549            if let Some(pool) = self.reusable_by_type.get_mut(&ct) {
550                if let Some((old_slot, node_id)) = pool.pop_front() {
551                    self.migrate_node_to_slot(node_id, old_slot, slot_id);
552                    self.update_reusable_count();
553                    return Some(node_id);
554                }
555            }
556        }
557
558        // Fallback: check untyped pool with policy compatibility
559        let position = self
560            .reusable_nodes_untyped
561            .iter()
562            .position(|(existing_slot, _)| self.policy.are_compatible(*existing_slot, slot_id));
563
564        if let Some(index) = position {
565            if let Some((old_slot, node_id)) = self.reusable_nodes_untyped.remove(index) {
566                self.migrate_node_to_slot(node_id, old_slot, slot_id);
567                self.update_reusable_count();
568                return Some(node_id);
569            }
570        }
571
572        None
573    }
574
575    /// Removes a node from whatever reusable pool it's in.
576    fn remove_from_reusable_pools(&mut self, node_id: NodeId) -> bool {
577        // Check typed pools
578        for pool in self.reusable_by_type.values_mut() {
579            if let Some(pos) = pool.iter().position(|(_, n)| *n == node_id) {
580                pool.remove(pos);
581                return true;
582            }
583        }
584        // Check untyped pool
585        if let Some(pos) = self
586            .reusable_nodes_untyped
587            .iter()
588            .position(|(_, n)| *n == node_id)
589        {
590            self.reusable_nodes_untyped.remove(pos);
591            return true;
592        }
593        false
594    }
595
596    /// Migrates a node from one slot to another, updating mappings.
597    fn migrate_node_to_slot(&mut self, node_id: NodeId, old_slot: SlotId, new_slot: SlotId) {
598        self.mapping.remove_by_node(&node_id);
599        self.mapping.add_node(new_slot, node_id);
600        if let Some(nodes) = self.precomposed_nodes.get_mut(&old_slot) {
601            nodes.retain(|candidate| *candidate != node_id);
602            if nodes.is_empty() {
603                self.precomposed_nodes.remove(&old_slot);
604            }
605        }
606    }
607
608    /// Updates the reusable_count from all pools.
609    fn update_reusable_count(&mut self) {
610        self.reusable_count = self
611            .reusable_by_type
612            .values()
613            .map(|p| p.len())
614            .sum::<usize>()
615            + self.reusable_nodes_untyped.len();
616    }
617
618    /// Moves active slots starting from `start_index` to the reusable bucket.
619    /// Returns the list of node ids that were DISPOSED (not just moved to reusable).
620    /// Nodes that exceed max_reusable_per_type are disposed instead of cached.
621    pub fn dispose_or_reuse_starting_from_index(&mut self, start_index: usize) -> Vec<NodeId> {
622        if start_index >= self.active_order.len() {
623            return Vec::new();
624        }
625
626        let retain = self
627            .policy
628            .get_slots_to_retain(&self.active_order[start_index..]);
629        let mut retained = Vec::new();
630        while self.active_order.len() > start_index {
631            let slot = self.active_order.pop().expect("active_order not empty");
632            if retain.contains(&slot) {
633                retained.push(slot);
634                continue;
635            }
636            self.mapping.deactivate_slot(slot);
637
638            // Add nodes to appropriate content-type pool
639            let content_type = self.slot_content_types.get(&slot).copied();
640            if let Some(nodes) = self.mapping.get_nodes(&slot) {
641                for node in nodes {
642                    if let Some(ct) = content_type {
643                        self.reusable_by_type
644                            .entry(ct)
645                            .or_default()
646                            .push_back((slot, *node));
647                    } else {
648                        self.reusable_nodes_untyped.push_back((slot, *node));
649                    }
650                }
651            }
652        }
653        retained.reverse();
654        self.active_order.extend(retained);
655
656        // Enforce max_reusable_per_type limit per pool - dispose oldest nodes first (FIFO)
657        let mut disposed = Vec::new();
658
659        // Enforce limit on typed pools
660        for pool in self.reusable_by_type.values_mut() {
661            while pool.len() > self.max_reusable_per_type {
662                if let Some((_, node_id)) = pool.pop_front() {
663                    self.mapping.remove_by_node(&node_id);
664                    disposed.push(node_id);
665                }
666            }
667        }
668
669        // Enforce limit on untyped pool (uses separate, larger limit)
670        while self.reusable_nodes_untyped.len() > self.max_reusable_untyped {
671            if let Some((_, node_id)) = self.reusable_nodes_untyped.pop_front() {
672                self.mapping.remove_by_node(&node_id);
673                disposed.push(node_id);
674            }
675        }
676
677        self.update_reusable_count();
678        disposed
679    }
680
681    fn prune_inactive_slots(&mut self) {
682        let active: HashSet<SlotId> = self.active_order.iter().copied().collect();
683
684        // Collect reusable slots from all pools
685        let mut reusable_slots: HashSet<SlotId> = HashSet::default();
686        for pool in self.reusable_by_type.values() {
687            for (slot, _) in pool {
688                reusable_slots.insert(*slot);
689            }
690        }
691        for (slot, _) in &self.reusable_nodes_untyped {
692            reusable_slots.insert(*slot);
693        }
694
695        let mut keep_slots = active.clone();
696        keep_slots.extend(reusable_slots);
697        self.mapping.retain_slots(&keep_slots);
698
699        // Keep slot compositions for both active AND reusable slots.
700        // This ensures items can be reused without full recomposition when scrolling back.
701        // Only truly removed slots (not active, not reusable) should have their compositions cleared.
702        self.slot_compositions
703            .retain(|slot, _| keep_slots.contains(slot));
704        self.slot_callbacks
705            .retain(|slot, _| keep_slots.contains(slot));
706
707        // Clean up content type mappings for inactive slots
708        self.slot_content_types
709            .retain(|slot, _| keep_slots.contains(slot));
710
711        // Notify policy to prune its internal slot data
712        self.policy.prune_slots(&keep_slots);
713
714        // Track count before pruning to compute removed count
715        let before_count = self.precomposed_count;
716        let mut removed_from_precomposed = 0usize;
717        self.precomposed_nodes.retain(|slot, nodes| {
718            if active.contains(slot) {
719                true
720            } else {
721                removed_from_precomposed += nodes.len();
722                false
723            }
724        });
725
726        // Prune typed pools - retain only nodes that still have valid slots in mapping
727        for pool in self.reusable_by_type.values_mut() {
728            pool.retain(|(_, node)| self.mapping.get_slot(node).is_some());
729        }
730        // Remove empty typed pools
731        self.reusable_by_type.retain(|_, pool| !pool.is_empty());
732
733        // Prune untyped pool
734        self.reusable_nodes_untyped
735            .retain(|(_, node)| self.mapping.get_slot(node).is_some());
736
737        self.update_reusable_count();
738        self.precomposed_count = before_count.saturating_sub(removed_from_precomposed);
739    }
740
741    /// Returns a snapshot of currently reusable nodes.
742    pub fn reusable(&self) -> Vec<NodeId> {
743        let mut nodes: Vec<NodeId> = self
744            .reusable_by_type
745            .values()
746            .flat_map(|pool| pool.iter().map(|(_, n)| *n))
747            .collect();
748        nodes.extend(self.reusable_nodes_untyped.iter().map(|(_, n)| *n));
749        nodes
750    }
751
752    /// Returns the number of slots currently active (in use during this pass).
753    ///
754    /// This reflects the slots that were activated via `register_active()` during
755    /// the current measurement pass.
756    pub fn active_slots_count(&self) -> usize {
757        self.active_order.len()
758    }
759
760    /// Returns the number of reusable slots in the pool.
761    ///
762    /// These are slots that were previously active but are now available for reuse
763    /// by compatible content types.
764    pub fn reusable_slots_count(&self) -> usize {
765        self.reusable_count
766    }
767
768    /// Invalidates all tracked subcomposition scopes.
769    ///
770    /// Hosts should call this when parent-captured inputs change without directly invalidating
771    /// the child scopes themselves. The next subcompose pass will then re-run active slot
772    /// content instead of skipping with stale captures.
773    pub fn invalidate_scopes(&self) {
774        self.mapping.invalidate_scopes();
775    }
776
777    /// Returns whether the last slot registered via [`register_active`] was reused.
778    ///
779    /// Returns `Some(true)` if the slot already existed (was reused from pool or
780    /// was recomposed), `Some(false)` if it was newly created, or `None` if no
781    /// slot has been registered yet this pass.
782    ///
783    /// This is useful for tracking composition statistics in lazy layouts.
784    pub fn was_last_slot_reused(&self) -> Option<bool> {
785        self.last_slot_reused
786    }
787
788    #[doc(hidden)]
789    pub fn debug_scope_ids_by_slot(&self) -> Vec<(u64, Vec<usize>)> {
790        self.mapping
791            .slot_to_scopes
792            .iter()
793            .map(|(slot, scopes)| (slot.raw(), scopes.iter().map(RecomposeScope::id).collect()))
794            .collect()
795    }
796
797    #[doc(hidden)]
798    pub fn debug_slot_table_for_slot(&self, slot_id: SlotId) -> Option<Vec<(usize, String)>> {
799        let slots = self.slot_compositions.get(&slot_id)?;
800        Some(slots.borrow().debug_dump_all_slots())
801    }
802
803    #[doc(hidden)]
804    pub fn debug_slot_table_groups_for_slot(&self, slot_id: SlotId) -> Option<Vec<DebugSlotGroup>> {
805        let slots = self.slot_compositions.get(&slot_id)?;
806        Some(slots.borrow().debug_dump_groups())
807    }
808
809    /// Returns a snapshot of precomposed nodes.
810    pub fn precomposed(&self) -> &HashMap<SlotId, Vec<NodeId>> {
811        &self.precomposed_nodes
812    }
813
814    /// Removes any precomposed nodes whose slots were not activated during the
815    /// current pass and returns their identifiers for disposal.
816    pub fn drain_inactive_precomposed(&mut self) -> Vec<NodeId> {
817        let active: HashSet<SlotId> = self.active_order.iter().copied().collect();
818        let mut disposed = Vec::new();
819        let mut empty_slots = Vec::new();
820        for (slot, nodes) in self.precomposed_nodes.iter_mut() {
821            if !active.contains(slot) {
822                disposed.extend(nodes.iter().copied());
823                empty_slots.push(*slot);
824            }
825        }
826        for slot in empty_slots {
827            self.precomposed_nodes.remove(&slot);
828        }
829        // disposed.len() is the exact count of nodes removed
830        self.precomposed_count = self.precomposed_count.saturating_sub(disposed.len());
831        disposed
832    }
833}
834
835#[cfg(test)]
836#[path = "tests/subcompose_tests.rs"]
837mod tests;