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