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::{CallbackHolder, 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 invalidate_scopes(&self) {
279        for scopes in self.slot_to_scopes.values() {
280            for scope in scopes {
281                scope.invalidate();
282            }
283        }
284    }
285
286    fn retain_slots(&mut self, active: &HashSet<SlotId>) -> Vec<NodeId> {
287        let mut removed_nodes = Vec::new();
288        self.slot_to_nodes.retain(|slot, nodes| {
289            if active.contains(slot) {
290                true
291            } else {
292                removed_nodes.extend(nodes.iter().copied());
293                false
294            }
295        });
296        self.slot_to_scopes.retain(|slot, _| active.contains(slot));
297        for node in &removed_nodes {
298            self.node_to_slot.remove(node);
299        }
300        removed_nodes
301    }
302}
303
304/// Tracks the state of nodes produced by subcomposition, enabling reuse between
305/// measurement passes.
306pub struct SubcomposeState {
307    mapping: NodeSlotMapping,
308    active_order: Vec<SlotId>, // FUTURE(no_std): replace Vec with bounded ordering buffer.
309    /// Per-content-type reusable node pools for O(1) compatible node lookup.
310    /// Key is content type, value is a deque of (SlotId, NodeId) pairs.
311    /// Nodes without content type go to `reusable_nodes_untyped`.
312    reusable_by_type: HashMap<u64, VecDeque<(SlotId, NodeId)>>,
313    /// Reusable nodes without a content type (fallback pool).
314    reusable_nodes_untyped: VecDeque<(SlotId, NodeId)>,
315    /// Maps slot to its content type for efficient lookup during reuse.
316    slot_content_types: HashMap<SlotId, u64>,
317    precomposed_nodes: HashMap<SlotId, Vec<NodeId>>, // FUTURE(no_std): use arena-backed precomposition lists.
318    policy: Box<dyn SlotReusePolicy>,
319    pub(crate) current_index: usize,
320    pub(crate) reusable_count: usize,
321    pub(crate) precomposed_count: usize,
322    /// Per-slot SlotsHost for isolated compositions.
323    /// Each SlotId gets its own slot table, avoiding cursor-based conflicts
324    /// when items are subcomposed in different orders.
325    slot_compositions: HashMap<SlotId, Rc<SlotsHost>>,
326    /// Latest slot root callbacks keyed by slot id.
327    slot_callbacks: HashMap<SlotId, CallbackHolder>,
328    /// Maximum number of reusable slots to keep cached per content type.
329    max_reusable_per_type: usize,
330    /// Maximum number of reusable slots for the untyped pool.
331    max_reusable_untyped: usize,
332    /// Whether the last slot registered via register_active was reused.
333    /// Set during register_active, read via was_last_slot_reused().
334    last_slot_reused: Option<bool>,
335}
336
337impl fmt::Debug for SubcomposeState {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        f.debug_struct("SubcomposeState")
340            .field("mapping", &self.mapping)
341            .field("active_order", &self.active_order)
342            .field("reusable_by_type_count", &self.reusable_by_type.len())
343            .field("reusable_untyped_count", &self.reusable_nodes_untyped.len())
344            .field("precomposed_nodes", &self.precomposed_nodes)
345            .field("current_index", &self.current_index)
346            .field("reusable_count", &self.reusable_count)
347            .field("precomposed_count", &self.precomposed_count)
348            .field("slot_compositions_count", &self.slot_compositions.len())
349            .finish()
350    }
351}
352
353impl Default for SubcomposeState {
354    fn default() -> Self {
355        Self::new(Box::new(DefaultSlotReusePolicy))
356    }
357}
358
359/// Default maximum reusable slots to cache per content type.
360/// With multiple content types, total reusable = this * number_of_types.
361const DEFAULT_MAX_REUSABLE_PER_TYPE: usize = 5;
362
363/// Default maximum reusable slots for the untyped pool.
364/// This is higher than per-type since all items without content types share this pool.
365/// Matches RecyclerView's default cache size.
366const DEFAULT_MAX_REUSABLE_UNTYPED: usize = 10;
367
368impl SubcomposeState {
369    /// Creates a new [`SubcomposeState`] using the supplied reuse policy.
370    pub fn new(policy: Box<dyn SlotReusePolicy>) -> Self {
371        Self {
372            mapping: NodeSlotMapping::default(),
373            active_order: Vec::new(),
374            reusable_by_type: HashMap::default(),
375            reusable_nodes_untyped: VecDeque::new(),
376            slot_content_types: HashMap::default(),
377            precomposed_nodes: HashMap::default(), // FUTURE(no_std): initialize arena-backed precomposition map.
378            policy,
379            current_index: 0,
380            reusable_count: 0,
381            precomposed_count: 0,
382            slot_compositions: HashMap::default(),
383            slot_callbacks: HashMap::default(),
384            max_reusable_per_type: DEFAULT_MAX_REUSABLE_PER_TYPE,
385            max_reusable_untyped: DEFAULT_MAX_REUSABLE_UNTYPED,
386            last_slot_reused: None,
387        }
388    }
389
390    /// Sets the policy used for future reuse decisions.
391    pub fn set_policy(&mut self, policy: Box<dyn SlotReusePolicy>) {
392        self.policy = policy;
393    }
394
395    /// Registers a content type for a slot.
396    ///
397    /// Stores the content type locally for efficient pool-based reuse lookup,
398    /// and also delegates to the policy for compatibility checking.
399    ///
400    /// Call this before subcomposing an item to enable content-type-aware slot reuse.
401    pub fn register_content_type(&mut self, slot_id: SlotId, content_type: u64) {
402        self.slot_content_types.insert(slot_id, content_type);
403        self.policy.register_content_type(slot_id, content_type);
404    }
405
406    /// Updates the content type for a slot, handling Some→None transitions.
407    ///
408    /// If `content_type` is `Some(type)`, registers the type for the slot.
409    /// If `content_type` is `None`, removes any previously registered type.
410    /// This ensures stale types don't drive incorrect reuse.
411    pub fn update_content_type(&mut self, slot_id: SlotId, content_type: Option<u64>) {
412        match content_type {
413            Some(ct) => self.register_content_type(slot_id, ct),
414            None => {
415                self.slot_content_types.remove(&slot_id);
416                self.policy.remove_content_type(slot_id);
417            }
418        }
419    }
420
421    /// Returns the content type for a slot, if registered.
422    pub fn get_content_type(&self, slot_id: SlotId) -> Option<u64> {
423        self.slot_content_types.get(&slot_id).copied()
424    }
425
426    /// Starts a new subcompose pass.
427    ///
428    /// Call this before subcomposing the current frame so the state can
429    /// track which slots are active and dispose the inactive ones later.
430    pub fn begin_pass(&mut self) {
431        self.current_index = 0;
432    }
433
434    /// Finishes a subcompose pass, disposing slots that were not used.
435    pub fn finish_pass(&mut self) -> Vec<NodeId> {
436        let disposed = self.dispose_or_reuse_starting_from_index(self.current_index);
437        self.prune_inactive_slots();
438        disposed
439    }
440
441    /// Returns the SlotsHost for the given slot ID, creating a new one if it doesn't exist.
442    /// Each slot gets its own isolated slot table, avoiding cursor-based conflicts when
443    /// items are subcomposed in different orders.
444    pub fn get_or_create_slots(&mut self, slot_id: SlotId) -> Rc<SlotsHost> {
445        Rc::clone(self.slot_compositions.entry(slot_id).or_insert_with(|| {
446            Rc::new(SlotsHost::new(crate::slot_backend::SlotBackend::Baseline(
447                SlotTable::new(),
448            )))
449        }))
450    }
451
452    /// Returns the latest callback holder for the given slot, creating one if needed.
453    pub fn callback_holder(&mut self, slot_id: SlotId) -> CallbackHolder {
454        self.slot_callbacks.entry(slot_id).or_default().clone()
455    }
456
457    /// Records that the nodes in `node_ids` are currently rendering the provided
458    /// `slot_id`.
459    pub fn register_active(
460        &mut self,
461        slot_id: SlotId,
462        node_ids: &[NodeId],
463        scopes: &[RecomposeScope],
464    ) {
465        // Track whether this slot was reused (had existing nodes before this call)
466        let was_reused =
467            self.mapping.get_nodes(&slot_id).is_some() || self.active_order.contains(&slot_id);
468        self.last_slot_reused = Some(was_reused);
469
470        if let Some(position) = self.active_order.iter().position(|slot| *slot == slot_id) {
471            if position < self.current_index {
472                for scope in scopes {
473                    scope.reactivate();
474                }
475                self.mapping.set_nodes(slot_id, node_ids);
476                self.mapping.set_scopes(slot_id, scopes);
477                if let Some(nodes) = self.precomposed_nodes.get_mut(&slot_id) {
478                    let before_len = nodes.len();
479                    nodes.retain(|node| !node_ids.contains(node));
480                    let removed = before_len - nodes.len();
481                    self.precomposed_count = self.precomposed_count.saturating_sub(removed);
482                    if nodes.is_empty() {
483                        self.precomposed_nodes.remove(&slot_id);
484                    }
485                }
486                return;
487            }
488            self.active_order.remove(position);
489        }
490        for scope in scopes {
491            scope.reactivate();
492        }
493        self.mapping.set_nodes(slot_id, node_ids);
494        self.mapping.set_scopes(slot_id, scopes);
495        if let Some(nodes) = self.precomposed_nodes.get_mut(&slot_id) {
496            let before_len = nodes.len();
497            nodes.retain(|node| !node_ids.contains(node));
498            let removed = before_len - nodes.len();
499            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
500            if nodes.is_empty() {
501                self.precomposed_nodes.remove(&slot_id);
502            }
503        }
504        let insert_at = self.current_index.min(self.active_order.len());
505        self.active_order.insert(insert_at, slot_id);
506        self.current_index += 1;
507    }
508
509    /// Stores a precomposed node for the provided slot. Precomposed nodes stay
510    /// detached from the tree until they are activated by `register_active`.
511    pub fn register_precomposed(&mut self, slot_id: SlotId, node_id: NodeId) {
512        self.precomposed_nodes
513            .entry(slot_id)
514            .or_default()
515            .push(node_id);
516        self.precomposed_count += 1;
517    }
518
519    /// Returns the node that previously rendered this slot, if it is still
520    /// considered reusable. Uses O(1) content-type based lookup when available.
521    ///
522    /// Lookup order:
523    /// 1. Exact slot match in the appropriate pool
524    /// 2. Any compatible node from the same content-type pool (O(1) pop)
525    /// 3. Fallback to untyped pool with policy compatibility check
526    pub fn take_node_from_reusables(&mut self, slot_id: SlotId) -> Option<NodeId> {
527        // First, try to find an exact slot match in mapping
528        // CRITICAL FIX: Return active nodes directly without requiring them to be in
529        // reusable pools. During multi-pass measurement, nodes registered via register_active
530        // are in the mapping but NOT in reusable pools (pools only get populated in finish_pass).
531        // Without this fix, new virtual nodes are created each measure pass, losing children.
532        if let Some(nodes) = self.mapping.get_nodes(&slot_id) {
533            let first_node = nodes.first().copied();
534            if let Some(node_id) = first_node {
535                // Try to remove from reusable pools if present (for nodes that were deactivated)
536                let _ = self.remove_from_reusable_pools(node_id);
537                self.update_reusable_count();
538                return Some(node_id);
539            }
540        }
541
542        // Get the content type for the requested slot
543        let content_type = self.slot_content_types.get(&slot_id).copied();
544
545        // Try to get a node from the same content-type pool (O(1))
546        if let Some(ct) = content_type {
547            if let Some(pool) = self.reusable_by_type.get_mut(&ct) {
548                if let Some((old_slot, node_id)) = pool.pop_front() {
549                    self.migrate_node_to_slot(node_id, old_slot, slot_id);
550                    self.update_reusable_count();
551                    return Some(node_id);
552                }
553            }
554        }
555
556        // Fallback: check untyped pool with policy compatibility
557        let position = self
558            .reusable_nodes_untyped
559            .iter()
560            .position(|(existing_slot, _)| self.policy.are_compatible(*existing_slot, slot_id));
561
562        if let Some(index) = position {
563            if let Some((old_slot, node_id)) = self.reusable_nodes_untyped.remove(index) {
564                self.migrate_node_to_slot(node_id, old_slot, slot_id);
565                self.update_reusable_count();
566                return Some(node_id);
567            }
568        }
569
570        None
571    }
572
573    /// Removes a node from whatever reusable pool it's in.
574    fn remove_from_reusable_pools(&mut self, node_id: NodeId) -> bool {
575        // Check typed pools
576        for pool in self.reusable_by_type.values_mut() {
577            if let Some(pos) = pool.iter().position(|(_, n)| *n == node_id) {
578                pool.remove(pos);
579                return true;
580            }
581        }
582        // Check untyped pool
583        if let Some(pos) = self
584            .reusable_nodes_untyped
585            .iter()
586            .position(|(_, n)| *n == node_id)
587        {
588            self.reusable_nodes_untyped.remove(pos);
589            return true;
590        }
591        false
592    }
593
594    /// Migrates a node from one slot to another, updating mappings.
595    fn migrate_node_to_slot(&mut self, node_id: NodeId, old_slot: SlotId, new_slot: SlotId) {
596        self.mapping.remove_by_node(&node_id);
597        self.mapping.add_node(new_slot, node_id);
598        if let Some(nodes) = self.precomposed_nodes.get_mut(&old_slot) {
599            nodes.retain(|candidate| *candidate != node_id);
600            if nodes.is_empty() {
601                self.precomposed_nodes.remove(&old_slot);
602            }
603        }
604    }
605
606    /// Updates the reusable_count from all pools.
607    fn update_reusable_count(&mut self) {
608        self.reusable_count = self
609            .reusable_by_type
610            .values()
611            .map(|p| p.len())
612            .sum::<usize>()
613            + self.reusable_nodes_untyped.len();
614    }
615
616    /// Moves active slots starting from `start_index` to the reusable bucket.
617    /// Returns the list of node ids that were DISPOSED (not just moved to reusable).
618    /// Nodes that exceed max_reusable_per_type are disposed instead of cached.
619    pub fn dispose_or_reuse_starting_from_index(&mut self, start_index: usize) -> Vec<NodeId> {
620        // FUTURE(no_std): return iterator over bounded node buffer.
621        if start_index >= self.active_order.len() {
622            return Vec::new();
623        }
624
625        let retain = self
626            .policy
627            .get_slots_to_retain(&self.active_order[start_index..]);
628        let mut retained = Vec::new();
629        while self.active_order.len() > start_index {
630            let slot = self.active_order.pop().expect("active_order not empty");
631            if retain.contains(&slot) {
632                retained.push(slot);
633                continue;
634            }
635            self.mapping.deactivate_slot(slot);
636
637            // Add nodes to appropriate content-type pool
638            let content_type = self.slot_content_types.get(&slot).copied();
639            if let Some(nodes) = self.mapping.get_nodes(&slot) {
640                for node in nodes {
641                    if let Some(ct) = content_type {
642                        self.reusable_by_type
643                            .entry(ct)
644                            .or_default()
645                            .push_back((slot, *node));
646                    } else {
647                        self.reusable_nodes_untyped.push_back((slot, *node));
648                    }
649                }
650            }
651        }
652        retained.reverse();
653        self.active_order.extend(retained);
654
655        // Enforce max_reusable_per_type limit per pool - dispose oldest nodes first (FIFO)
656        let mut disposed = Vec::new();
657
658        // Enforce limit on typed pools
659        for pool in self.reusable_by_type.values_mut() {
660            while pool.len() > self.max_reusable_per_type {
661                if let Some((_, node_id)) = pool.pop_front() {
662                    self.mapping.remove_by_node(&node_id);
663                    disposed.push(node_id);
664                }
665            }
666        }
667
668        // Enforce limit on untyped pool (uses separate, larger limit)
669        while self.reusable_nodes_untyped.len() > self.max_reusable_untyped {
670            if let Some((_, node_id)) = self.reusable_nodes_untyped.pop_front() {
671                self.mapping.remove_by_node(&node_id);
672                disposed.push(node_id);
673            }
674        }
675
676        self.update_reusable_count();
677        disposed
678    }
679
680    fn prune_inactive_slots(&mut self) {
681        let active: HashSet<SlotId> = self.active_order.iter().copied().collect();
682
683        // Collect reusable slots from all pools
684        let mut reusable_slots: HashSet<SlotId> = HashSet::default();
685        for pool in self.reusable_by_type.values() {
686            for (slot, _) in pool {
687                reusable_slots.insert(*slot);
688            }
689        }
690        for (slot, _) in &self.reusable_nodes_untyped {
691            reusable_slots.insert(*slot);
692        }
693
694        let mut keep_slots = active.clone();
695        keep_slots.extend(reusable_slots);
696        self.mapping.retain_slots(&keep_slots);
697
698        // Keep slot compositions for both active AND reusable slots.
699        // This ensures items can be reused without full recomposition when scrolling back.
700        // Only truly removed slots (not active, not reusable) should have their compositions cleared.
701        self.slot_compositions
702            .retain(|slot, _| keep_slots.contains(slot));
703        self.slot_callbacks
704            .retain(|slot, _| keep_slots.contains(slot));
705
706        // Clean up content type mappings for inactive slots
707        self.slot_content_types
708            .retain(|slot, _| keep_slots.contains(slot));
709
710        // Notify policy to prune its internal slot data
711        self.policy.prune_slots(&keep_slots);
712
713        // Track count before pruning to compute removed count
714        let before_count = self.precomposed_count;
715        let mut removed_from_precomposed = 0usize;
716        self.precomposed_nodes.retain(|slot, nodes| {
717            if active.contains(slot) {
718                true
719            } else {
720                removed_from_precomposed += nodes.len();
721                false
722            }
723        });
724
725        // Prune typed pools - retain only nodes that still have valid slots in mapping
726        for pool in self.reusable_by_type.values_mut() {
727            pool.retain(|(_, node)| self.mapping.get_slot(node).is_some());
728        }
729        // Remove empty typed pools
730        self.reusable_by_type.retain(|_, pool| !pool.is_empty());
731
732        // Prune untyped pool
733        self.reusable_nodes_untyped
734            .retain(|(_, node)| self.mapping.get_slot(node).is_some());
735
736        self.update_reusable_count();
737        self.precomposed_count = before_count.saturating_sub(removed_from_precomposed);
738    }
739
740    /// Returns a snapshot of currently reusable nodes.
741    pub fn reusable(&self) -> Vec<NodeId> {
742        let mut nodes: Vec<NodeId> = self
743            .reusable_by_type
744            .values()
745            .flat_map(|pool| pool.iter().map(|(_, n)| *n))
746            .collect();
747        nodes.extend(self.reusable_nodes_untyped.iter().map(|(_, n)| *n));
748        nodes
749    }
750
751    /// Returns the number of slots currently active (in use during this pass).
752    ///
753    /// This reflects the slots that were activated via `register_active()` during
754    /// the current measurement pass.
755    pub fn active_slots_count(&self) -> usize {
756        self.active_order.len()
757    }
758
759    /// Returns the number of reusable slots in the pool.
760    ///
761    /// These are slots that were previously active but are now available for reuse
762    /// by compatible content types.
763    pub fn reusable_slots_count(&self) -> usize {
764        self.reusable_count
765    }
766
767    /// Invalidates all tracked subcomposition scopes.
768    ///
769    /// Hosts should call this when parent-captured inputs change without directly invalidating
770    /// the child scopes themselves. The next subcompose pass will then re-run active slot
771    /// content instead of skipping with stale captures.
772    pub fn invalidate_scopes(&self) {
773        self.mapping.invalidate_scopes();
774    }
775
776    /// Returns whether the last slot registered via [`register_active`] was reused.
777    ///
778    /// Returns `Some(true)` if the slot already existed (was reused from pool or
779    /// was recomposed), `Some(false)` if it was newly created, or `None` if no
780    /// slot has been registered yet this pass.
781    ///
782    /// This is useful for tracking composition statistics in lazy layouts.
783    pub fn was_last_slot_reused(&self) -> Option<bool> {
784        self.last_slot_reused
785    }
786
787    /// Returns a snapshot of precomposed nodes.
788    pub fn precomposed(&self) -> &HashMap<SlotId, Vec<NodeId>> {
789        // FUTURE(no_std): expose arena-backed view without HashMap.
790        &self.precomposed_nodes
791    }
792
793    /// Removes any precomposed nodes whose slots were not activated during the
794    /// current pass and returns their identifiers for disposal.
795    pub fn drain_inactive_precomposed(&mut self) -> Vec<NodeId> {
796        // FUTURE(no_std): drain into smallvec buffer.
797        let active: HashSet<SlotId> = self.active_order.iter().copied().collect();
798        let mut disposed = Vec::new();
799        let mut empty_slots = Vec::new();
800        for (slot, nodes) in self.precomposed_nodes.iter_mut() {
801            if !active.contains(slot) {
802                disposed.extend(nodes.iter().copied());
803                empty_slots.push(*slot);
804            }
805        }
806        for slot in empty_slots {
807            self.precomposed_nodes.remove(&slot);
808        }
809        // disposed.len() is the exact count of nodes removed
810        self.precomposed_count = self.precomposed_count.saturating_sub(disposed.len());
811        disposed
812    }
813}
814
815#[cfg(test)]
816#[path = "tests/subcompose_tests.rs"]
817mod tests;