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