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 move 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        if existing == requested {
176            return true;
177        }
178
179        let types = self.slot_types.borrow();
180        match (types.get(&existing), types.get(&requested)) {
181            (Some(existing_type), Some(requested_type)) => existing_type == requested_type,
182            (None, None) => true,
183            _ => false,
184        }
185    }
186
187    fn register_content_type(&self, slot_id: SlotId, content_type: u64) {
188        self.set_content_type(slot_id, content_type);
189    }
190
191    fn remove_content_type(&self, slot_id: SlotId) {
192        ContentTypeReusePolicy::remove_content_type(self, slot_id);
193    }
194}
195
196#[doc(hidden)]
197pub struct ExactSlotActivation {
198    pub nodes: Vec<NodeId>,
199    pub scopes: Vec<RecomposeScope>,
200    pub reactivate_scopes: bool,
201}
202
203#[derive(Default, Clone)]
204
205struct NodeSlotMapping {
206    slot_to_nodes: HashMap<SlotId, Vec<NodeId>>,
207    node_to_slot: HashMap<NodeId, SlotId>,
208    slot_to_scopes: HashMap<SlotId, Vec<RecomposeScope>>,
209}
210
211impl fmt::Debug for NodeSlotMapping {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        f.debug_struct("NodeSlotMapping")
214            .field("slot_to_nodes", &self.slot_to_nodes)
215            .field("node_to_slot", &self.node_to_slot)
216            .finish()
217    }
218}
219
220impl NodeSlotMapping {
221    fn set_nodes(&mut self, slot: SlotId, nodes: &[NodeId]) {
222        self.slot_to_nodes.insert(slot, nodes.to_vec());
223        for node in nodes {
224            self.node_to_slot.insert(*node, slot);
225        }
226    }
227
228    fn set_scopes(&mut self, slot: SlotId, scopes: &[RecomposeScope]) {
229        self.slot_to_scopes.insert(slot, scopes.to_vec());
230    }
231
232    fn add_node(&mut self, slot: SlotId, node: NodeId) {
233        self.slot_to_nodes.entry(slot).or_default().push(node);
234        self.node_to_slot.insert(node, slot);
235    }
236
237    fn remove_by_node(&mut self, node: &NodeId) -> Option<SlotId> {
238        if let Some(slot) = self.node_to_slot.remove(node) {
239            if let Some(nodes) = self.slot_to_nodes.get_mut(&slot) {
240                if let Some(index) = nodes.iter().position(|candidate| candidate == node) {
241                    nodes.remove(index);
242                }
243                if nodes.is_empty() {
244                    self.slot_to_nodes.remove(&slot);
245                    // Also clean up slot_to_scopes when slot becomes empty
246                    self.slot_to_scopes.remove(&slot);
247                }
248            }
249            Some(slot)
250        } else {
251            None
252        }
253    }
254
255    fn get_nodes(&self, slot: &SlotId) -> Option<&[NodeId]> {
256        self.slot_to_nodes.get(slot).map(|nodes| nodes.as_slice())
257    }
258
259    fn get_scopes(&self, slot: &SlotId) -> Option<&[RecomposeScope]> {
260        self.slot_to_scopes
261            .get(slot)
262            .map(|scopes| scopes.as_slice())
263    }
264
265    fn slot_has_invalid_scopes(&self, slot: SlotId) -> bool {
266        self.slot_to_scopes
267            .get(&slot)
268            .is_some_and(|scopes| scopes.iter().any(RecomposeScope::is_invalid))
269    }
270
271    fn deactivate_slot(&self, slot: SlotId) {
272        if let Some(scopes) = self.slot_to_scopes.get(&slot) {
273            for scope in scopes {
274                scope.deactivate();
275            }
276        }
277    }
278
279    fn invalidate_scopes(&self) {
280        for scopes in self.slot_to_scopes.values() {
281            for scope in scopes {
282                scope.invalidate();
283            }
284        }
285    }
286}
287
288/// Tracks the state of nodes produced by subcomposition, enabling reuse between
289/// measurement passes.
290pub struct SubcomposeState {
291    mapping: NodeSlotMapping,
292    active_order: Vec<SlotId>,
293    live_slots: HashSet<SlotId>,
294    current_pass_active_slots: HashSet<SlotId>,
295    /// Per-content-type reusable node pools used to narrow compatible node lookup.
296    /// Key is content type, value is a deque of (SlotId, NodeId) pairs.
297    /// Nodes without content type go to `reusable_nodes_untyped`.
298    reusable_by_type: HashMap<u64, VecDeque<(SlotId, NodeId)>>,
299    /// Reusable nodes without a content type (fallback pool).
300    reusable_nodes_untyped: VecDeque<(SlotId, NodeId)>,
301    reusable_node_counts: HashMap<SlotId, usize>,
302    exact_reactivation_slots: HashSet<SlotId>,
303    /// Maps slot to its content type for efficient lookup during reuse.
304    slot_content_types: HashMap<SlotId, u64>,
305    precomposed_nodes: HashMap<SlotId, Vec<NodeId>>,
306    policy: Box<dyn SlotReusePolicy>,
307    pub(crate) current_index: usize,
308    pub(crate) reusable_count: usize,
309    pub(crate) precomposed_count: usize,
310    /// Per-slot SlotsHost for isolated compositions.
311    /// Each SlotId gets its own slot table, avoiding cursor-based conflicts
312    /// when items are subcomposed in different orders.
313    slot_compositions: HashMap<SlotId, Rc<SlotsHost>>,
314    /// Latest slot root callbacks keyed by slot id.
315    slot_callbacks: HashMap<SlotId, CallbackHolder>,
316    /// Maximum number of reusable slots to keep cached per content type.
317    max_reusable_per_type: usize,
318    /// Maximum number of reusable slots for the untyped pool.
319    max_reusable_untyped: usize,
320    /// Whether the last slot registered via register_active was reused.
321    /// Set during register_active, read via was_last_slot_reused().
322    last_slot_reused: Option<bool>,
323}
324
325impl fmt::Debug for SubcomposeState {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        f.debug_struct("SubcomposeState")
328            .field("mapping", &self.mapping)
329            .field("active_order", &self.active_order)
330            .field("reusable_by_type_count", &self.reusable_by_type.len())
331            .field("reusable_untyped_count", &self.reusable_nodes_untyped.len())
332            .field("precomposed_nodes", &self.precomposed_nodes)
333            .field("current_index", &self.current_index)
334            .field("reusable_count", &self.reusable_count)
335            .field("precomposed_count", &self.precomposed_count)
336            .field("slot_compositions_count", &self.slot_compositions.len())
337            .finish()
338    }
339}
340
341impl Default for SubcomposeState {
342    fn default() -> Self {
343        Self::new(Box::new(DefaultSlotReusePolicy))
344    }
345}
346
347/// Default maximum reusable slots to cache per content type.
348/// With multiple content types, total reusable = this * number_of_types.
349const DEFAULT_MAX_REUSABLE_PER_TYPE: usize = 5;
350
351/// Default maximum reusable slots for the untyped pool.
352/// This is higher than per-type since all items without content types share this pool.
353/// Matches RecyclerView's default cache size.
354const DEFAULT_MAX_REUSABLE_UNTYPED: usize = 10;
355
356impl SubcomposeState {
357    /// Creates a new [`SubcomposeState`] using the supplied reuse policy.
358    pub fn new(policy: Box<dyn SlotReusePolicy>) -> Self {
359        Self {
360            mapping: NodeSlotMapping::default(),
361            active_order: Vec::new(),
362            live_slots: HashSet::default(),
363            current_pass_active_slots: HashSet::default(),
364            reusable_by_type: HashMap::default(),
365            reusable_nodes_untyped: VecDeque::new(),
366            reusable_node_counts: HashMap::default(),
367            exact_reactivation_slots: HashSet::default(),
368            slot_content_types: HashMap::default(),
369            precomposed_nodes: HashMap::default(),
370            policy,
371            current_index: 0,
372            reusable_count: 0,
373            precomposed_count: 0,
374            slot_compositions: HashMap::default(),
375            slot_callbacks: HashMap::default(),
376            max_reusable_per_type: DEFAULT_MAX_REUSABLE_PER_TYPE,
377            max_reusable_untyped: DEFAULT_MAX_REUSABLE_UNTYPED,
378            last_slot_reused: None,
379        }
380    }
381
382    /// Sets the policy used for future reuse decisions.
383    pub fn set_policy(&mut self, policy: Box<dyn SlotReusePolicy>) {
384        self.policy = policy;
385    }
386
387    pub fn set_reusable_pool_limits(&mut self, per_type: usize, untyped: usize) {
388        self.max_reusable_per_type = per_type;
389        self.max_reusable_untyped = untyped;
390    }
391
392    /// Registers a content type for a slot.
393    ///
394    /// Stores the content type locally for efficient pool-based reuse lookup,
395    /// and also delegates to the policy for compatibility checking.
396    ///
397    /// Call this before subcomposing an item to enable content-type-aware slot reuse.
398    pub fn register_content_type(&mut self, slot_id: SlotId, content_type: u64) {
399        self.slot_content_types.insert(slot_id, content_type);
400        self.policy.register_content_type(slot_id, content_type);
401    }
402
403    /// Updates the content type for a slot, handling optional content types.
404    ///
405    /// If `content_type` is `Some(type)`, registers the type for the slot.
406    /// If `content_type` is `None`, removes any previously registered type.
407    /// This ensures stale types don't drive incorrect reuse.
408    pub fn update_content_type(&mut self, slot_id: SlotId, content_type: Option<u64>) {
409        match content_type {
410            Some(ct) => self.register_content_type(slot_id, ct),
411            None => {
412                self.slot_content_types.remove(&slot_id);
413                self.policy.remove_content_type(slot_id);
414            }
415        }
416    }
417
418    /// Returns the content type for a slot, if registered.
419    pub fn get_content_type(&self, slot_id: SlotId) -> Option<u64> {
420        self.slot_content_types.get(&slot_id).copied()
421    }
422
423    /// Starts a new subcompose pass.
424    ///
425    /// Call this before subcomposing the current frame so the state can
426    /// track which slots are active and dispose the inactive ones later.
427    pub fn begin_pass(&mut self) {
428        self.current_index = 0;
429        self.current_pass_active_slots.clear();
430    }
431
432    /// Returns the current active slot cursor for the in-progress pass.
433    pub fn active_slot_cursor(&self) -> usize {
434        self.current_index
435    }
436
437    /// Restores the active slot cursor for work that should not become part of
438    /// the rendered active set, such as lazy-list prefetch measurement.
439    pub fn restore_active_slot_cursor(&mut self, cursor: usize) {
440        self.current_index = cursor.min(self.active_order.len());
441    }
442
443    /// Moves an active slot out of the rendered set and into the reusable pool.
444    pub fn recycle_active_slot(&mut self, slot_id: SlotId) -> Vec<NodeId> {
445        self.recycle_active_slot_internal(slot_id, false)
446    }
447
448    pub fn recycle_prefetched_active_slot(&mut self, slot_id: SlotId) -> Vec<NodeId> {
449        self.recycle_active_slot_internal(slot_id, true)
450    }
451
452    pub fn recycle_active_slots_where(
453        &mut self,
454        mut predicate: impl FnMut(SlotId) -> bool,
455    ) -> Vec<NodeId> {
456        let slots: Vec<_> = self
457            .active_order
458            .iter()
459            .copied()
460            .filter(|slot| !self.current_pass_active_slots.contains(slot))
461            .filter(|slot| predicate(*slot))
462            .collect();
463        let mut disposed = Vec::new();
464        for slot in slots {
465            disposed.extend(self.recycle_active_slot(slot));
466        }
467        disposed
468    }
469
470    fn recycle_active_slot_internal(
471        &mut self,
472        slot_id: SlotId,
473        allow_exact_reactivation: bool,
474    ) -> Vec<NodeId> {
475        let Some(position) = self
476            .active_order
477            .iter()
478            .position(|candidate| *candidate == slot_id)
479        else {
480            return Vec::new();
481        };
482        self.active_order.remove(position);
483        if position < self.current_index {
484            self.current_index = self.current_index.saturating_sub(1);
485        }
486        self.move_slot_to_reusable(slot_id, allow_exact_reactivation);
487        self.enforce_reusable_pool_limits()
488    }
489
490    /// Finishes a subcompose pass, disposing slots that were not used.
491    pub fn finish_pass(&mut self) -> Vec<NodeId> {
492        self.dispose_or_reuse_starting_from_index(self.current_index)
493    }
494
495    /// Returns the SlotsHost for the given slot ID, creating a new one if it doesn't exist.
496    /// Each slot gets its own isolated slot table, avoiding cursor-based conflicts when
497    /// items are subcomposed in different orders.
498    pub fn get_or_create_slots(&mut self, slot_id: SlotId) -> Rc<SlotsHost> {
499        Rc::clone(
500            self.slot_compositions
501                .entry(slot_id)
502                .or_insert_with(|| Rc::new(SlotsHost::new(SlotTable::new()))),
503        )
504    }
505
506    /// Returns the latest callback holder for the given slot, creating one if needed.
507    pub fn callback_holder(&mut self, slot_id: SlotId) -> CallbackHolder {
508        self.slot_callbacks.entry(slot_id).or_default().clone()
509    }
510
511    #[doc(hidden)]
512    pub fn activate_current_active_slot(&mut self, slot_id: SlotId) -> Option<Vec<NodeId>> {
513        if self.current_pass_active_slots.contains(&slot_id)
514            || self.exact_reactivation_slots.contains(&slot_id)
515            || self
516                .active_order
517                .get(self.current_index)
518                .copied()
519                .is_none_or(|active_slot| active_slot != slot_id)
520            || self.mapping.slot_has_invalid_scopes(slot_id)
521        {
522            return None;
523        }
524
525        let nodes = self.mapping.get_nodes(&slot_id)?.to_vec();
526        self.last_slot_reused = Some(true);
527        self.live_slots.insert(slot_id);
528        self.current_pass_active_slots.insert(slot_id);
529        self.current_index += 1;
530        Some(nodes)
531    }
532
533    #[doc(hidden)]
534    pub fn take_exact_slot_activation(&mut self, slot_id: SlotId) -> Option<ExactSlotActivation> {
535        let active_position = self
536            .active_order
537            .iter()
538            .position(|candidate| *candidate == slot_id);
539        let is_exact_reactivation = self.exact_reactivation_slots.contains(&slot_id);
540        if active_position.is_none() && !is_exact_reactivation
541            || self.mapping.slot_has_invalid_scopes(slot_id)
542        {
543            return None;
544        }
545
546        let nodes = self.mapping.get_nodes(&slot_id)?.to_vec();
547        let scopes = self
548            .mapping
549            .get_scopes(&slot_id)
550            .unwrap_or_default()
551            .to_vec();
552        if active_position.is_none() {
553            for node in &nodes {
554                let _ = self.remove_from_reusable_pools(*node);
555            }
556        }
557        self.exact_reactivation_slots.remove(&slot_id);
558        Some(ExactSlotActivation {
559            nodes,
560            scopes,
561            reactivate_scopes: active_position.is_none(),
562        })
563    }
564
565    #[doc(hidden)]
566    pub fn take_exact_slot_for_activation(
567        &mut self,
568        slot_id: SlotId,
569    ) -> Option<(Vec<NodeId>, Vec<RecomposeScope>)> {
570        self.take_exact_slot_activation(slot_id)
571            .map(|activation| (activation.nodes, activation.scopes))
572    }
573
574    /// Records that the nodes in `node_ids` are currently rendering the provided
575    /// `slot_id`.
576    pub fn register_active(
577        &mut self,
578        slot_id: SlotId,
579        node_ids: &[NodeId],
580        scopes: &[RecomposeScope],
581    ) {
582        self.register_active_with_scope_reactivation(slot_id, node_ids, scopes, true);
583    }
584
585    #[doc(hidden)]
586    pub fn register_active_with_scope_reactivation(
587        &mut self,
588        slot_id: SlotId,
589        node_ids: &[NodeId],
590        scopes: &[RecomposeScope],
591        reactivate_scopes: bool,
592    ) {
593        let was_reused = self.mapping.get_nodes(&slot_id).is_some();
594        self.last_slot_reused = Some(was_reused);
595        self.live_slots.insert(slot_id);
596        self.current_pass_active_slots.insert(slot_id);
597        self.exact_reactivation_slots.remove(&slot_id);
598
599        if let Some(position) = self.active_order.iter().position(|slot| *slot == slot_id) {
600            if position < self.current_index {
601                if reactivate_scopes {
602                    for scope in scopes {
603                        scope.reactivate();
604                    }
605                }
606                self.update_active_slot_mapping(slot_id, node_ids, scopes);
607                return;
608            }
609            self.active_order.remove(position);
610        }
611        if reactivate_scopes {
612            for scope in scopes {
613                scope.reactivate();
614            }
615        }
616        self.update_active_slot_mapping(slot_id, node_ids, scopes);
617        let insert_at = self.current_index.min(self.active_order.len());
618        self.active_order.insert(insert_at, slot_id);
619        self.current_index += 1;
620    }
621
622    fn update_active_slot_mapping(
623        &mut self,
624        slot_id: SlotId,
625        node_ids: &[NodeId],
626        scopes: &[RecomposeScope],
627    ) {
628        self.mapping.set_nodes(slot_id, node_ids);
629        self.mapping.set_scopes(slot_id, scopes);
630        if let Some(nodes) = self.precomposed_nodes.get_mut(&slot_id) {
631            let before_len = nodes.len();
632            nodes.retain(|node| !node_ids.contains(node));
633            let removed = before_len - nodes.len();
634            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
635            if nodes.is_empty() {
636                self.precomposed_nodes.remove(&slot_id);
637            }
638        }
639    }
640
641    /// Stores a precomposed node for the provided slot. Precomposed nodes stay
642    /// detached from the tree until they are activated by `register_active`.
643    pub fn register_precomposed(&mut self, slot_id: SlotId, node_id: NodeId) {
644        self.precomposed_nodes
645            .entry(slot_id)
646            .or_default()
647            .push(node_id);
648        self.precomposed_count += 1;
649    }
650
651    fn increment_reusable_slot(&mut self, slot_id: SlotId) {
652        *self.reusable_node_counts.entry(slot_id).or_insert(0) += 1;
653        self.reusable_count += 1;
654    }
655
656    fn decrement_reusable_slot(&mut self, slot_id: SlotId) {
657        let Some(entry) = self.reusable_node_counts.get_mut(&slot_id) else {
658            self.reusable_count = self.reusable_count.saturating_sub(1);
659            return;
660        };
661        *entry = entry.saturating_sub(1);
662        if *entry == 0 {
663            self.reusable_node_counts.remove(&slot_id);
664        }
665        self.reusable_count = self.reusable_count.saturating_sub(1);
666    }
667
668    fn prune_slot_if_unused(&mut self, slot_id: SlotId) {
669        if self.live_slots.contains(&slot_id) || self.reusable_node_counts.contains_key(&slot_id) {
670            return;
671        }
672
673        debug_assert!(
674            self.mapping.get_nodes(&slot_id).is_none(),
675            "inactive slot {slot_id:?} still has mapped nodes",
676        );
677
678        self.slot_compositions.remove(&slot_id);
679        self.slot_callbacks.remove(&slot_id);
680        self.slot_content_types.remove(&slot_id);
681        self.policy.remove_content_type(slot_id);
682        if let Some(nodes) = self.precomposed_nodes.remove(&slot_id) {
683            self.precomposed_count = self.precomposed_count.saturating_sub(nodes.len());
684        }
685    }
686
687    /// Returns the node that previously rendered this slot, if it is still
688    /// considered reusable. Content-type buckets narrow the candidate set, then
689    /// the reuse policy makes the final compatibility decision.
690    ///
691    /// Lookup order:
692    /// 1. Exact slot match in the appropriate pool
693    /// 2. Any policy-compatible node from the same content-type pool
694    /// 3. Fallback to untyped pool with policy compatibility check
695    pub fn take_node_from_reusables(&mut self, slot_id: SlotId) -> Option<NodeId> {
696        if let Some(nodes) = self.mapping.get_nodes(&slot_id) {
697            let first_node = nodes.first().copied();
698            if let Some(node_id) = first_node {
699                let _ = self.remove_from_reusable_pools(node_id);
700                self.exact_reactivation_slots.remove(&slot_id);
701                return Some(node_id);
702            }
703        }
704
705        let content_type = self.slot_content_types.get(&slot_id).copied();
706
707        if let Some(ct) = content_type {
708            if let Some((old_slot, node_id)) = self.take_compatible_typed_reusable(ct, slot_id) {
709                self.decrement_reusable_slot(old_slot);
710                self.move_node_to_slot(node_id, old_slot, slot_id);
711                return Some(node_id);
712            }
713        }
714
715        let exact_reactivation_slots = &self.exact_reactivation_slots;
716        let policy = &self.policy;
717        let position = self
718            .reusable_nodes_untyped
719            .iter()
720            .position(|(existing_slot, _)| {
721                !exact_reactivation_slots.contains(existing_slot)
722                    && policy.are_compatible(*existing_slot, slot_id)
723            });
724
725        if let Some(index) = position {
726            if let Some((old_slot, node_id)) = self.reusable_nodes_untyped.remove(index) {
727                self.decrement_reusable_slot(old_slot);
728                self.move_node_to_slot(node_id, old_slot, slot_id);
729                return Some(node_id);
730            }
731        }
732
733        None
734    }
735
736    fn take_compatible_typed_reusable(
737        &mut self,
738        content_type: u64,
739        requested_slot: SlotId,
740    ) -> Option<(SlotId, NodeId)> {
741        let reused = {
742            let policy = &self.policy;
743            let exact_reactivation_slots = &self.exact_reactivation_slots;
744            let pool = self.reusable_by_type.get_mut(&content_type)?;
745            let index = pool.iter().position(|(existing_slot, _)| {
746                !exact_reactivation_slots.contains(existing_slot)
747                    && policy.are_compatible(*existing_slot, requested_slot)
748            })?;
749            pool.remove(index)
750        };
751
752        if self
753            .reusable_by_type
754            .get(&content_type)
755            .map(|pool| pool.is_empty())
756            .unwrap_or(false)
757        {
758            self.reusable_by_type.remove(&content_type);
759        }
760
761        reused
762    }
763
764    /// Removes a node from whatever reusable pool it's in.
765    fn remove_from_reusable_pools(&mut self, node_id: NodeId) -> Option<SlotId> {
766        // Check typed pools
767        let mut typed_match = None;
768        for (&content_type, pool) in &self.reusable_by_type {
769            if let Some(position) = pool
770                .iter()
771                .position(|(_, pooled_node)| *pooled_node == node_id)
772            {
773                typed_match = Some((content_type, position));
774                break;
775            }
776        }
777        if let Some((content_type, position)) = typed_match {
778            let pool = self.reusable_by_type.get_mut(&content_type)?;
779            let (slot, _) = pool.remove(position)?;
780            if pool.is_empty() {
781                self.reusable_by_type.remove(&content_type);
782            }
783            self.decrement_reusable_slot(slot);
784            return Some(slot);
785        }
786        // Check untyped pool
787        if let Some(position) = self
788            .reusable_nodes_untyped
789            .iter()
790            .position(|(_, n)| *n == node_id)
791        {
792            if let Some((slot, _)) = self.reusable_nodes_untyped.remove(position) {
793                self.decrement_reusable_slot(slot);
794                return Some(slot);
795            }
796        }
797        None
798    }
799
800    /// Moves a node from one slot to another, updating mappings.
801    fn move_node_to_slot(&mut self, node_id: NodeId, old_slot: SlotId, new_slot: SlotId) {
802        if old_slot == new_slot {
803            return;
804        }
805
806        self.mapping.remove_by_node(&node_id);
807        self.exact_reactivation_slots.remove(&old_slot);
808        self.mapping.add_node(new_slot, node_id);
809        self.slot_content_types.remove(&old_slot);
810        self.policy.remove_content_type(old_slot);
811        if let Some(slots) = self.slot_compositions.remove(&old_slot) {
812            self.slot_compositions.insert(new_slot, slots);
813        }
814        if let Some(callback) = self.slot_callbacks.remove(&old_slot) {
815            self.slot_callbacks.insert(new_slot, callback);
816        }
817        if let Some(nodes) = self.precomposed_nodes.get_mut(&old_slot) {
818            let before_len = nodes.len();
819            nodes.retain(|candidate| *candidate != node_id);
820            let removed = before_len - nodes.len();
821            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
822            if nodes.is_empty() {
823                self.precomposed_nodes.remove(&old_slot);
824            }
825        }
826    }
827
828    /// Moves active slots starting from `start_index` to the reusable bucket.
829    /// Returns the list of node ids that were DISPOSED (not just moved to reusable).
830    /// Nodes that exceed max_reusable_per_type are disposed instead of cached.
831    pub fn dispose_or_reuse_starting_from_index(&mut self, start_index: usize) -> Vec<NodeId> {
832        if start_index >= self.active_order.len() {
833            return Vec::new();
834        }
835
836        let retain = self
837            .policy
838            .get_slots_to_retain(&self.active_order[start_index..]);
839        let mut retained = Vec::new();
840        while self.active_order.len() > start_index {
841            let Some(slot) = self.active_order.pop() else {
842                break;
843            };
844            if retain.contains(&slot) {
845                retained.push(slot);
846                continue;
847            }
848            self.move_slot_to_reusable(slot, false);
849        }
850        retained.reverse();
851        self.active_order.extend(retained);
852
853        self.enforce_reusable_pool_limits()
854    }
855
856    fn move_slot_to_reusable(&mut self, slot: SlotId, allow_exact_reactivation: bool) {
857        self.live_slots.remove(&slot);
858        self.current_pass_active_slots.remove(&slot);
859        self.mapping.deactivate_slot(slot);
860        if allow_exact_reactivation {
861            self.exact_reactivation_slots.insert(slot);
862        } else {
863            self.exact_reactivation_slots.remove(&slot);
864        }
865
866        let content_type = self.slot_content_types.get(&slot).copied();
867        let nodes: SmallVec<[NodeId; 4]> = self
868            .mapping
869            .get_nodes(&slot)
870            .into_iter()
871            .flatten()
872            .copied()
873            .collect();
874        for node in nodes {
875            if let Some(ct) = content_type {
876                self.reusable_by_type
877                    .entry(ct)
878                    .or_default()
879                    .push_back((slot, node));
880            } else {
881                self.reusable_nodes_untyped.push_back((slot, node));
882            }
883            self.increment_reusable_slot(slot);
884        }
885    }
886
887    fn enforce_reusable_pool_limits(&mut self) -> Vec<NodeId> {
888        let mut disposed = Vec::new();
889        let mut typed_disposals = Vec::new();
890        for pool in self.reusable_by_type.values_mut() {
891            while pool.len() > self.max_reusable_per_type {
892                if let Some((slot, node_id)) = pool.pop_front() {
893                    typed_disposals.push((slot, node_id));
894                }
895            }
896        }
897        for (slot, node_id) in typed_disposals {
898            self.decrement_reusable_slot(slot);
899            self.mapping.remove_by_node(&node_id);
900            self.exact_reactivation_slots.remove(&slot);
901            disposed.push(node_id);
902        }
903
904        // Enforce limit on untyped pool (uses separate, larger limit)
905        while self.reusable_nodes_untyped.len() > self.max_reusable_untyped {
906            if let Some((slot, node_id)) = self.reusable_nodes_untyped.pop_front() {
907                self.decrement_reusable_slot(slot);
908                self.mapping.remove_by_node(&node_id);
909                self.exact_reactivation_slots.remove(&slot);
910                disposed.push(node_id);
911            }
912        }
913
914        self.reusable_by_type.retain(|_, pool| !pool.is_empty());
915        disposed
916    }
917
918    /// Returns a snapshot of currently reusable nodes.
919    pub fn reusable(&self) -> Vec<NodeId> {
920        let mut nodes: Vec<NodeId> = self
921            .reusable_by_type
922            .values()
923            .flat_map(|pool| pool.iter().map(|(_, n)| *n))
924            .collect();
925        nodes.extend(self.reusable_nodes_untyped.iter().map(|(_, n)| *n));
926        nodes
927    }
928
929    /// Returns the number of slots currently active (in use during this pass).
930    ///
931    /// This reflects the slots that were activated via `register_active()` during
932    /// the current measurement pass.
933    pub fn active_slots_count(&self) -> usize {
934        self.active_order.len()
935    }
936
937    /// Returns the number of reusable slots in the pool.
938    ///
939    /// These are slots that were previously active but are now available for reuse
940    /// by compatible content types.
941    pub fn reusable_slots_count(&self) -> usize {
942        self.reusable_count
943    }
944
945    /// Invalidates all tracked subcomposition scopes.
946    ///
947    /// Hosts should call this when parent-captured inputs change without directly invalidating
948    /// the child scopes themselves. The next subcompose pass will then re-run active slot
949    /// content instead of skipping with stale captures.
950    pub fn invalidate_scopes(&self) {
951        self.mapping.invalidate_scopes();
952    }
953
954    /// Returns whether the last slot registered via [`register_active`] was reused.
955    ///
956    /// Returns `Some(true)` if the slot already existed (was reused from pool or
957    /// was recomposed), `Some(false)` if it was newly created, or `None` if no
958    /// slot has been registered yet this pass.
959    ///
960    /// This is useful for tracking composition statistics in lazy layouts.
961    pub fn was_last_slot_reused(&self) -> Option<bool> {
962        self.last_slot_reused
963    }
964
965    #[doc(hidden)]
966    pub fn debug_scope_ids_by_slot(&self) -> Vec<(u64, Vec<usize>)> {
967        self.mapping
968            .slot_to_scopes
969            .iter()
970            .map(|(slot, scopes)| (slot.raw(), scopes.iter().map(RecomposeScope::id).collect()))
971            .collect()
972    }
973
974    #[doc(hidden)]
975    pub fn debug_slot_table_for_slot(&self, slot_id: SlotId) -> Option<Vec<crate::SlotDebugEntry>> {
976        let slots = self.slot_compositions.get(&slot_id)?;
977        Some(slots.borrow().debug_dump_slot_entries())
978    }
979
980    #[doc(hidden)]
981    pub fn debug_slot_table_groups_for_slot(&self, slot_id: SlotId) -> Option<Vec<DebugSlotGroup>> {
982        let slots = self.slot_compositions.get(&slot_id)?;
983        Some(slots.borrow().debug_dump_groups())
984    }
985
986    /// Returns a snapshot of precomposed nodes.
987    pub fn precomposed(&self) -> &HashMap<SlotId, Vec<NodeId>> {
988        &self.precomposed_nodes
989    }
990
991    /// Removes any precomposed nodes whose slots were not activated during the
992    /// current pass and returns their identifiers for disposal.
993    pub fn drain_inactive_precomposed(&mut self) -> Vec<NodeId> {
994        let mut disposed = Vec::new();
995        let mut empty_slots = Vec::new();
996        for (slot, nodes) in self.precomposed_nodes.iter_mut() {
997            if !self.current_pass_active_slots.contains(slot) {
998                disposed.extend(nodes.iter().copied());
999                empty_slots.push(*slot);
1000            }
1001        }
1002        for slot in empty_slots {
1003            self.precomposed_nodes.remove(&slot);
1004            self.prune_slot_if_unused(slot);
1005        }
1006        // disposed.len() is the exact count of nodes removed
1007        self.precomposed_count = self.precomposed_count.saturating_sub(disposed.len());
1008        disposed
1009    }
1010}
1011
1012#[cfg(test)]
1013#[path = "tests/subcompose_tests.rs"]
1014mod tests;