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 std::{any::Any, collections::VecDeque, fmt, rc::Rc};
10
11use smallvec::SmallVec;
12
13use crate::{
14    CallbackHolder, NodeId, RecomposeScope, SlotTable, SlotsHost,
15    collections::map::{HashMap, HashSet},
16};
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
67    /// Removes the content type for a slot (e.g., when transitioning to None).
68    ///
69    /// Policies that track content types should override this to clean up.
70    /// The default implementation is a no-op.
71    fn remove_content_type(&self, _slot_id: SlotId) {}
72}
73
74/// Default reuse policy that mirrors Jetpack Compose behaviour: dispose
75/// everything from the tail so that the next measurement can decide which
76/// content to keep alive. Compatibility defaults to exact slot matches.
77#[derive(Debug, Default)]
78pub struct DefaultSlotReusePolicy;
79
80impl SlotReusePolicy for DefaultSlotReusePolicy {
81    fn get_slots_to_retain(&self, active: &[SlotId]) -> HashSet<SlotId> {
82        let _ = active;
83        HashSet::default()
84    }
85
86    fn are_compatible(&self, existing: SlotId, requested: SlotId) -> bool {
87        existing == requested
88    }
89}
90
91/// Reuse policy that allows cross-slot reuse when content types match.
92///
93/// This policy enables efficient recycling of layout nodes across different
94/// slot IDs when they share the same content type (e.g., list items with
95/// similar structure but different data).
96///
97/// # Example
98///
99/// ```rust,ignore
100/// use cranpose_core::{ContentTypeReusePolicy, SubcomposeState, SlotId};
101///
102/// let mut policy = ContentTypeReusePolicy::new();
103///
104/// // Register content types for slots
105/// policy.set_content_type(SlotId::new(0), 1); // Header type
106/// policy.set_content_type(SlotId::new(1), 2); // Item type
107/// policy.set_content_type(SlotId::new(2), 2); // Item type (same as slot 1)
108///
109/// // Slot 1 can reuse slot 2's node since they share content type 2
110/// assert!(policy.are_compatible(SlotId::new(2), SlotId::new(1)));
111/// ```
112pub struct ContentTypeReusePolicy {
113    slot_types: std::cell::RefCell<HashMap<SlotId, u64>>,
114}
115
116impl std::fmt::Debug for ContentTypeReusePolicy {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        let types = self.slot_types.borrow();
119        f.debug_struct("ContentTypeReusePolicy")
120            .field("slot_types", &*types)
121            .finish()
122    }
123}
124
125impl Default for ContentTypeReusePolicy {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl ContentTypeReusePolicy {
132    /// Creates a new content-type-aware reuse policy.
133    pub fn new() -> Self {
134        Self {
135            slot_types: std::cell::RefCell::new(HashMap::default()),
136        }
137    }
138
139    /// Registers the content type for a slot.
140    ///
141    /// Call this when subcomposing an item with a known content type.
142    pub fn set_content_type(&self, slot: SlotId, content_type: u64) {
143        self.slot_types.borrow_mut().insert(slot, content_type);
144    }
145
146    /// Removes the content type for a slot (e.g., when disposed).
147    pub fn remove_content_type(&self, slot: SlotId) {
148        self.slot_types.borrow_mut().remove(&slot);
149    }
150
151    /// Clears all registered content types.
152    pub fn clear(&self) {
153        self.slot_types.borrow_mut().clear();
154    }
155
156    /// Returns the content type for a slot, if registered.
157    pub fn get_content_type(&self, slot: SlotId) -> Option<u64> {
158        self.slot_types.borrow().get(&slot).copied()
159    }
160}
161
162impl SlotReusePolicy for ContentTypeReusePolicy {
163    fn get_slots_to_retain(&self, active: &[SlotId]) -> HashSet<SlotId> {
164        let _ = active;
165        HashSet::default()
166    }
167
168    fn are_compatible(&self, existing: SlotId, requested: SlotId) -> bool {
169        if existing == requested {
170            return true;
171        }
172
173        let types = self.slot_types.borrow();
174        match (types.get(&existing), types.get(&requested)) {
175            (Some(existing_type), Some(requested_type)) => existing_type == requested_type,
176            (None, None) => true,
177            _ => false,
178        }
179    }
180
181    fn register_content_type(&self, slot_id: SlotId, content_type: u64) {
182        self.set_content_type(slot_id, content_type);
183    }
184
185    fn remove_content_type(&self, slot_id: SlotId) {
186        ContentTypeReusePolicy::remove_content_type(self, slot_id);
187    }
188}
189
190#[doc(hidden)]
191pub struct ExactSlotActivation {
192    pub nodes: Vec<NodeId>,
193    pub scopes: Vec<RecomposeScope>,
194    pub reactivate_scopes: bool,
195}
196
197#[derive(Default, Clone)]
198
199struct NodeSlotMapping {
200    slot_to_nodes: HashMap<SlotId, Vec<NodeId>>,
201    node_to_slot: HashMap<NodeId, SlotId>,
202    slot_to_scopes: HashMap<SlotId, Vec<RecomposeScope>>,
203}
204
205impl fmt::Debug for NodeSlotMapping {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        f.debug_struct("NodeSlotMapping")
208            .field("slot_to_nodes", &self.slot_to_nodes)
209            .field("node_to_slot", &self.node_to_slot)
210            .finish()
211    }
212}
213
214impl NodeSlotMapping {
215    fn set_nodes(&mut self, slot: SlotId, nodes: &[NodeId]) {
216        self.slot_to_nodes.insert(slot, nodes.to_vec());
217        for node in nodes {
218            self.node_to_slot.insert(*node, slot);
219        }
220    }
221
222    fn set_scopes(&mut self, slot: SlotId, scopes: &[RecomposeScope]) {
223        self.slot_to_scopes.insert(slot, scopes.to_vec());
224    }
225
226    fn add_node(&mut self, slot: SlotId, node: NodeId) {
227        self.slot_to_nodes.entry(slot).or_default().push(node);
228        self.node_to_slot.insert(node, slot);
229    }
230
231    fn remove_by_node(&mut self, node: &NodeId) -> Option<SlotId> {
232        if let Some(slot) = self.node_to_slot.remove(node) {
233            if let Some(nodes) = self.slot_to_nodes.get_mut(&slot) {
234                if let Some(index) = nodes.iter().position(|candidate| candidate == node) {
235                    nodes.remove(index);
236                }
237                if nodes.is_empty() {
238                    self.slot_to_nodes.remove(&slot);
239                    self.slot_to_scopes.remove(&slot);
240                }
241            }
242            Some(slot)
243        } else {
244            None
245        }
246    }
247
248    fn get_nodes(&self, slot: &SlotId) -> Option<&[NodeId]> {
249        self.slot_to_nodes.get(slot).map(|nodes| nodes.as_slice())
250    }
251
252    fn get_scopes(&self, slot: &SlotId) -> Option<&[RecomposeScope]> {
253        self.slot_to_scopes
254            .get(slot)
255            .map(|scopes| scopes.as_slice())
256    }
257
258    fn slot_has_invalid_scopes(&self, slot: SlotId) -> bool {
259        self.slot_to_scopes
260            .get(&slot)
261            .is_some_and(|scopes| scopes.iter().any(RecomposeScope::is_invalid))
262    }
263
264    fn slot_has_inactive_scopes(&self, slot: SlotId) -> bool {
265        self.slot_to_scopes
266            .get(&slot)
267            .is_some_and(|scopes| scopes.iter().any(|scope| !scope.is_active()))
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
287/// Tracks the state of nodes produced by subcomposition, enabling reuse between
288/// measurement passes.
289pub struct SubcomposeState {
290    mapping: NodeSlotMapping,
291    active_order: Vec<SlotId>,
292    live_slots: HashSet<SlotId>,
293    current_pass_active_slots: HashSet<SlotId>,
294    reusable_by_type: HashMap<u64, VecDeque<(SlotId, NodeId)>>,
295    reusable_nodes_untyped: VecDeque<(SlotId, NodeId)>,
296    reusable_node_counts: HashMap<SlotId, usize>,
297    exact_reactivation_slots: HashSet<SlotId>,
298    slot_content_types: HashMap<SlotId, u64>,
299    precomposed_nodes: HashMap<SlotId, Vec<NodeId>>,
300    policy: Box<dyn SlotReusePolicy>,
301    pub(crate) current_index: usize,
302    pub(crate) reusable_count: usize,
303    pub(crate) precomposed_count: usize,
304    slot_compositions: HashMap<SlotId, Rc<SlotsHost>>,
305    slot_callbacks: HashMap<SlotId, CallbackHolder>,
306    max_reusable_per_type: usize,
307    max_reusable_untyped: usize,
308    last_slot_reused: Option<bool>,
309    retained_capture_keys: HashMap<SlotId, RetainedCaptureKey>,
310    content_generation: std::cell::Cell<u64>,
311    slot_composed_generation: HashMap<SlotId, (u64, u64)>,
312}
313
314struct RetainedCaptureKey {
315    value: Box<dyn Any>,
316    eq: fn(&dyn Any, &dyn Any) -> bool,
317}
318
319fn retained_capture_key_eq<K: PartialEq + 'static>(stored: &dyn Any, candidate: &dyn Any) -> bool {
320    match (stored.downcast_ref::<K>(), candidate.downcast_ref::<K>()) {
321        (Some(stored), Some(candidate)) => stored == candidate,
322        _ => false,
323    }
324}
325
326impl fmt::Debug for SubcomposeState {
327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328        f.debug_struct("SubcomposeState")
329            .field("mapping", &self.mapping)
330            .field("active_order", &self.active_order)
331            .field("reusable_by_type_count", &self.reusable_by_type.len())
332            .field("reusable_untyped_count", &self.reusable_nodes_untyped.len())
333            .field("precomposed_nodes", &self.precomposed_nodes)
334            .field("current_index", &self.current_index)
335            .field("reusable_count", &self.reusable_count)
336            .field("precomposed_count", &self.precomposed_count)
337            .field("slot_compositions_count", &self.slot_compositions.len())
338            .finish()
339    }
340}
341
342impl Default for SubcomposeState {
343    fn default() -> Self {
344        Self::new(Box::new(DefaultSlotReusePolicy))
345    }
346}
347
348const DEFAULT_MAX_REUSABLE_PER_TYPE: usize = 5;
349
350const DEFAULT_MAX_REUSABLE_UNTYPED: usize = 10;
351
352impl SubcomposeState {
353    /// Creates a new [`SubcomposeState`] using the supplied reuse policy.
354    pub fn new(policy: Box<dyn SlotReusePolicy>) -> Self {
355        Self {
356            mapping: NodeSlotMapping::default(),
357            active_order: Vec::new(),
358            live_slots: HashSet::default(),
359            current_pass_active_slots: HashSet::default(),
360            reusable_by_type: HashMap::default(),
361            reusable_nodes_untyped: VecDeque::new(),
362            reusable_node_counts: HashMap::default(),
363            exact_reactivation_slots: HashSet::default(),
364            slot_content_types: HashMap::default(),
365            precomposed_nodes: HashMap::default(),
366            policy,
367            current_index: 0,
368            reusable_count: 0,
369            precomposed_count: 0,
370            slot_compositions: HashMap::default(),
371            slot_callbacks: HashMap::default(),
372            max_reusable_per_type: DEFAULT_MAX_REUSABLE_PER_TYPE,
373            max_reusable_untyped: DEFAULT_MAX_REUSABLE_UNTYPED,
374            last_slot_reused: None,
375            retained_capture_keys: HashMap::default(),
376            content_generation: std::cell::Cell::new(0),
377            slot_composed_generation: HashMap::default(),
378        }
379    }
380
381    /// Sets the policy used for future reuse decisions.
382    pub fn set_policy(&mut self, policy: Box<dyn SlotReusePolicy>) {
383        self.policy = policy;
384    }
385
386    pub fn set_reusable_pool_limits(&mut self, per_type: usize, untyped: usize) {
387        self.max_reusable_per_type = per_type;
388        self.max_reusable_untyped = untyped;
389    }
390
391    /// Registers a content type for a slot.
392    ///
393    /// Stores the content type locally for efficient pool-based reuse lookup,
394    /// and also delegates to the policy for compatibility checking.
395    ///
396    /// Call this before subcomposing an item to enable content-type-aware slot reuse.
397    pub fn register_content_type(&mut self, slot_id: SlotId, content_type: u64) {
398        self.slot_content_types.insert(slot_id, content_type);
399        self.policy.register_content_type(slot_id, content_type);
400    }
401
402    /// Updates the content type for a slot, handling optional content types.
403    ///
404    /// If `content_type` is `Some(type)`, registers the type for the slot.
405    /// If `content_type` is `None`, removes any previously registered type.
406    /// This ensures stale types don't drive incorrect reuse.
407    pub fn update_content_type(&mut self, slot_id: SlotId, content_type: Option<u64>) {
408        match content_type {
409            Some(ct) => self.register_content_type(slot_id, ct),
410            None => {
411                self.slot_content_types.remove(&slot_id);
412                self.policy.remove_content_type(slot_id);
413            }
414        }
415    }
416
417    /// Returns the content type for a slot, if registered.
418    pub fn get_content_type(&self, slot_id: SlotId) -> Option<u64> {
419        self.slot_content_types.get(&slot_id).copied()
420    }
421
422    /// Returns the active slot that owns a registered root node.
423    ///
424    /// Descendants must be resolved to their slot root by the caller. Reusable
425    /// and removed slots are excluded.
426    pub fn active_slot_for_node(&self, node_id: NodeId) -> Option<SlotId> {
427        self.mapping
428            .node_to_slot
429            .get(&node_id)
430            .copied()
431            .filter(|slot| self.live_slots.contains(slot))
432    }
433
434    /// Starts a new subcompose pass.
435    ///
436    /// Call this before subcomposing the current frame so the state can
437    /// track which slots are active and dispose the inactive ones later.
438    pub fn begin_pass(&mut self) {
439        self.current_index = 0;
440        self.current_pass_active_slots.clear();
441    }
442
443    /// Returns the current active slot cursor for the in-progress pass.
444    pub fn active_slot_cursor(&self) -> usize {
445        self.current_index
446    }
447
448    /// Restores the active slot cursor for work that should not become part of
449    /// the rendered active set, such as lazy-list prefetch measurement.
450    pub fn restore_active_slot_cursor(&mut self, cursor: usize) {
451        self.current_index = cursor.min(self.active_order.len());
452    }
453
454    /// Moves an active slot out of the rendered set and into the reusable pool.
455    pub fn recycle_active_slot(&mut self, slot_id: SlotId) -> Vec<NodeId> {
456        self.recycle_active_slot_internal(slot_id, false)
457    }
458
459    pub fn recycle_prefetched_active_slot(&mut self, slot_id: SlotId) -> Vec<NodeId> {
460        self.recycle_active_slot_internal(slot_id, true)
461    }
462
463    pub fn recycle_active_slots_where(
464        &mut self,
465        mut predicate: impl FnMut(SlotId) -> bool,
466    ) -> Vec<NodeId> {
467        let slots: Vec<_> = self
468            .active_order
469            .iter()
470            .copied()
471            .filter(|slot| !self.current_pass_active_slots.contains(slot))
472            .filter(|slot| predicate(*slot))
473            .collect();
474        let mut disposed = Vec::new();
475        for slot in slots {
476            disposed.extend(self.recycle_active_slot(slot));
477        }
478        disposed
479    }
480
481    fn recycle_active_slot_internal(
482        &mut self,
483        slot_id: SlotId,
484        allow_exact_reactivation: bool,
485    ) -> Vec<NodeId> {
486        let Some(position) = self
487            .active_order
488            .iter()
489            .position(|candidate| *candidate == slot_id)
490        else {
491            return Vec::new();
492        };
493        self.active_order.remove(position);
494        if position < self.current_index {
495            self.current_index = self.current_index.saturating_sub(1);
496        }
497        self.move_slot_to_reusable(slot_id, allow_exact_reactivation);
498        self.enforce_reusable_pool_limits()
499    }
500
501    /// Finishes a subcompose pass, disposing slots that were not used.
502    pub fn finish_pass(&mut self) -> Vec<NodeId> {
503        self.dispose_or_reuse_starting_from_index(self.current_index)
504    }
505
506    /// Returns the SlotsHost for the given slot ID, creating a new one if it doesn't exist.
507    /// Each slot gets its own isolated slot table, avoiding cursor-based conflicts when
508    /// items are subcomposed in different orders.
509    pub fn get_or_create_slots(&mut self, slot_id: SlotId) -> Rc<SlotsHost> {
510        Rc::clone(
511            self.slot_compositions
512                .entry(slot_id)
513                .or_insert_with(|| Rc::new(SlotsHost::new(SlotTable::new()))),
514        )
515    }
516
517    /// Returns the latest callback holder for the given slot, creating one if needed.
518    pub fn callback_holder(&mut self, slot_id: SlotId) -> CallbackHolder {
519        self.slot_callbacks.entry(slot_id).or_default().clone()
520    }
521
522    #[doc(hidden)]
523    pub fn activate_current_active_slot(&mut self, slot_id: SlotId) -> Option<Vec<NodeId>> {
524        if self.current_pass_active_slots.contains(&slot_id)
525            || self.exact_reactivation_slots.contains(&slot_id)
526            || self.reusable_node_counts.contains_key(&slot_id)
527            || self
528                .active_order
529                .get(self.current_index)
530                .copied()
531                .is_none_or(|active_slot| active_slot != slot_id)
532            || self.mapping.slot_has_invalid_scopes(slot_id)
533            || self.mapping.slot_has_inactive_scopes(slot_id)
534        {
535            return None;
536        }
537
538        let nodes = self.mapping.get_nodes(&slot_id)?.to_vec();
539        self.last_slot_reused = Some(true);
540        self.live_slots.insert(slot_id);
541        self.current_pass_active_slots.insert(slot_id);
542        self.current_index += 1;
543        Some(nodes)
544    }
545
546    /// Whether precomposed nodes are waiting to be consumed for this slot.
547    /// A pending precomposition means the retained mapping is about to be
548    /// superseded, so clean-slot reuse must reject and compose.
549    pub fn has_pending_precompositions(&self, slot_id: SlotId) -> bool {
550        self.precomposed_nodes.contains_key(&slot_id)
551    }
552
553    /// Whether the slot's stored capture key equals `key`. A missing key never
554    /// matches: a slot must compose at least once under the key discipline
555    /// before it may skip.
556    pub fn retained_capture_key_matches<K: PartialEq + 'static>(
557        &self,
558        slot_id: SlotId,
559        key: &K,
560    ) -> bool {
561        self.retained_capture_keys
562            .get(&slot_id)
563            .is_some_and(|retained| (retained.eq)(retained.value.as_ref(), key))
564    }
565
566    /// Records the capture key the slot is being composed under.
567    pub fn store_retained_capture_key<K: PartialEq + 'static>(&mut self, slot_id: SlotId, key: K) {
568        self.retained_capture_keys.insert(
569            slot_id,
570            RetainedCaptureKey {
571                value: Box::new(key),
572                eq: retained_capture_key_eq::<K>,
573            },
574        );
575    }
576
577    #[doc(hidden)]
578    pub fn take_exact_slot_activation(&mut self, slot_id: SlotId) -> Option<ExactSlotActivation> {
579        let active_position = self
580            .active_order
581            .iter()
582            .position(|candidate| *candidate == slot_id);
583        let is_exact_reactivation = self.exact_reactivation_slots.contains(&slot_id);
584        if active_position.is_none() && !is_exact_reactivation
585            || self.mapping.slot_has_invalid_scopes(slot_id)
586        {
587            return None;
588        }
589
590        let nodes = self.mapping.get_nodes(&slot_id)?.to_vec();
591        let scopes = self
592            .mapping
593            .get_scopes(&slot_id)
594            .unwrap_or_default()
595            .to_vec();
596        if active_position.is_none() {
597            for node in &nodes {
598                let _ = self.remove_from_reusable_pools(*node);
599            }
600        }
601        self.exact_reactivation_slots.remove(&slot_id);
602        Some(ExactSlotActivation {
603            nodes,
604            scopes,
605            reactivate_scopes: active_position.is_none(),
606        })
607    }
608
609    #[doc(hidden)]
610    pub fn take_exact_slot_for_activation(
611        &mut self,
612        slot_id: SlotId,
613    ) -> Option<(Vec<NodeId>, Vec<RecomposeScope>)> {
614        self.take_exact_slot_activation(slot_id)
615            .map(|activation| (activation.nodes, activation.scopes))
616    }
617
618    /// Records that the nodes in `node_ids` are currently rendering the provided
619    /// `slot_id`.
620    pub fn register_active(
621        &mut self,
622        slot_id: SlotId,
623        node_ids: &[NodeId],
624        scopes: &[RecomposeScope],
625    ) {
626        self.register_active_with_scope_reactivation(slot_id, node_ids, scopes, true);
627    }
628
629    #[doc(hidden)]
630    pub fn register_active_with_scope_reactivation(
631        &mut self,
632        slot_id: SlotId,
633        node_ids: &[NodeId],
634        scopes: &[RecomposeScope],
635        reactivate_scopes: bool,
636    ) {
637        let was_reused = self.mapping.get_nodes(&slot_id).is_some();
638        self.last_slot_reused = Some(was_reused);
639        self.live_slots.insert(slot_id);
640        self.current_pass_active_slots.insert(slot_id);
641        self.exact_reactivation_slots.remove(&slot_id);
642
643        if let Some(position) = self.active_order.iter().position(|slot| *slot == slot_id) {
644            if position < self.current_index {
645                if reactivate_scopes {
646                    for scope in scopes {
647                        scope.reactivate();
648                    }
649                }
650                self.update_active_slot_mapping(slot_id, node_ids, scopes);
651                return;
652            }
653            self.active_order.remove(position);
654        }
655        if reactivate_scopes {
656            for scope in scopes {
657                scope.reactivate();
658            }
659        }
660        self.update_active_slot_mapping(slot_id, node_ids, scopes);
661        let insert_at = self.current_index.min(self.active_order.len());
662        self.active_order.insert(insert_at, slot_id);
663        self.current_index += 1;
664    }
665
666    fn update_active_slot_mapping(
667        &mut self,
668        slot_id: SlotId,
669        node_ids: &[NodeId],
670        scopes: &[RecomposeScope],
671    ) {
672        self.mapping.set_nodes(slot_id, node_ids);
673        self.mapping.set_scopes(slot_id, scopes);
674        if let Some(nodes) = self.precomposed_nodes.get_mut(&slot_id) {
675            let before_len = nodes.len();
676            nodes.retain(|node| !node_ids.contains(node));
677            let removed = before_len - nodes.len();
678            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
679            if nodes.is_empty() {
680                self.precomposed_nodes.remove(&slot_id);
681            }
682        }
683    }
684
685    /// Stores a precomposed node for the provided slot. Precomposed nodes stay
686    /// detached from the tree until they are activated by `register_active`.
687    pub fn register_precomposed(&mut self, slot_id: SlotId, node_id: NodeId) {
688        self.precomposed_nodes
689            .entry(slot_id)
690            .or_default()
691            .push(node_id);
692        self.precomposed_count += 1;
693    }
694
695    fn increment_reusable_slot(&mut self, slot_id: SlotId) {
696        *self.reusable_node_counts.entry(slot_id).or_insert(0) += 1;
697        self.reusable_count += 1;
698    }
699
700    fn decrement_reusable_slot(&mut self, slot_id: SlotId) {
701        let Some(entry) = self.reusable_node_counts.get_mut(&slot_id) else {
702            self.reusable_count = self.reusable_count.saturating_sub(1);
703            return;
704        };
705        *entry = entry.saturating_sub(1);
706        if *entry == 0 {
707            self.reusable_node_counts.remove(&slot_id);
708        }
709        self.reusable_count = self.reusable_count.saturating_sub(1);
710    }
711
712    fn prune_slot_if_unused(&mut self, slot_id: SlotId) {
713        if self.live_slots.contains(&slot_id) || self.reusable_node_counts.contains_key(&slot_id) {
714            return;
715        }
716
717        debug_assert!(
718            self.mapping.get_nodes(&slot_id).is_none(),
719            "inactive slot {slot_id:?} still has mapped nodes",
720        );
721
722        self.slot_compositions.remove(&slot_id);
723        self.slot_callbacks.remove(&slot_id);
724        self.slot_content_types.remove(&slot_id);
725        self.retained_capture_keys.remove(&slot_id);
726        self.slot_composed_generation.remove(&slot_id);
727        self.policy.remove_content_type(slot_id);
728        if let Some(nodes) = self.precomposed_nodes.remove(&slot_id) {
729            self.precomposed_count = self.precomposed_count.saturating_sub(nodes.len());
730        }
731    }
732
733    /// Returns the node that previously rendered this slot, if it is still
734    /// considered reusable, and whether it was REBOUND — taken from another
735    /// slot, so its retained subtree is about to show different content. An
736    /// exact-slot reactivation hands the same item its own subtree back and
737    /// is not a rebinding.
738    ///
739    /// Lookup order:
740    /// 1. Exact slot match in the appropriate pool (not a rebinding)
741    /// 2. Any policy-compatible node from the same content-type pool
742    /// 3. Fallback to untyped pool with policy compatibility check
743    pub fn take_node_from_reusables(&mut self, slot_id: SlotId) -> Option<(NodeId, bool)> {
744        if let Some(nodes) = self.mapping.get_nodes(&slot_id) {
745            let first_node = nodes.first().copied();
746            if let Some(node_id) = first_node {
747                let _ = self.remove_from_reusable_pools(node_id);
748                self.exact_reactivation_slots.remove(&slot_id);
749                return Some((node_id, false));
750            }
751        }
752
753        let content_type = self.slot_content_types.get(&slot_id).copied();
754
755        if let Some(ct) = content_type
756            && let Some((old_slot, node_id)) = self.take_compatible_typed_reusable(ct, slot_id)
757        {
758            self.decrement_reusable_slot(old_slot);
759            self.move_node_to_slot(node_id, old_slot, slot_id);
760            return Some((node_id, true));
761        }
762
763        let exact_reactivation_slots = &self.exact_reactivation_slots;
764        let policy = &self.policy;
765        let position = self
766            .reusable_nodes_untyped
767            .iter()
768            .position(|(existing_slot, _)| {
769                !exact_reactivation_slots.contains(existing_slot)
770                    && policy.are_compatible(*existing_slot, slot_id)
771            });
772
773        if let Some(index) = position
774            && let Some((old_slot, node_id)) = self.reusable_nodes_untyped.remove(index)
775        {
776            self.decrement_reusable_slot(old_slot);
777            self.move_node_to_slot(node_id, old_slot, slot_id);
778            return Some((node_id, true));
779        }
780
781        None
782    }
783
784    fn take_compatible_typed_reusable(
785        &mut self,
786        content_type: u64,
787        requested_slot: SlotId,
788    ) -> Option<(SlotId, NodeId)> {
789        let reused = {
790            let policy = &self.policy;
791            let exact_reactivation_slots = &self.exact_reactivation_slots;
792            let pool = self.reusable_by_type.get_mut(&content_type)?;
793            let index = pool.iter().position(|(existing_slot, _)| {
794                !exact_reactivation_slots.contains(existing_slot)
795                    && policy.are_compatible(*existing_slot, requested_slot)
796            })?;
797            pool.remove(index)
798        };
799
800        if self
801            .reusable_by_type
802            .get(&content_type)
803            .map(|pool| pool.is_empty())
804            .unwrap_or(false)
805        {
806            self.reusable_by_type.remove(&content_type);
807        }
808
809        reused
810    }
811
812    fn remove_from_reusable_pools(&mut self, node_id: NodeId) -> Option<SlotId> {
813        let mut typed_match = None;
814        for (&content_type, pool) in &self.reusable_by_type {
815            if let Some(position) = pool
816                .iter()
817                .position(|(_, pooled_node)| *pooled_node == node_id)
818            {
819                typed_match = Some((content_type, position));
820                break;
821            }
822        }
823        if let Some((content_type, position)) = typed_match {
824            let pool = self.reusable_by_type.get_mut(&content_type)?;
825            let (slot, _) = pool.remove(position)?;
826            if pool.is_empty() {
827                self.reusable_by_type.remove(&content_type);
828            }
829            self.decrement_reusable_slot(slot);
830            return Some(slot);
831        }
832        if let Some(position) = self
833            .reusable_nodes_untyped
834            .iter()
835            .position(|(_, n)| *n == node_id)
836            && let Some((slot, _)) = self.reusable_nodes_untyped.remove(position)
837        {
838            self.decrement_reusable_slot(slot);
839            return Some(slot);
840        }
841        None
842    }
843
844    fn move_node_to_slot(&mut self, node_id: NodeId, old_slot: SlotId, new_slot: SlotId) {
845        if old_slot == new_slot {
846            return;
847        }
848
849        self.mapping.remove_by_node(&node_id);
850        self.exact_reactivation_slots.remove(&old_slot);
851        self.mapping.add_node(new_slot, node_id);
852        self.slot_content_types.remove(&old_slot);
853        self.retained_capture_keys.remove(&old_slot);
854        self.retained_capture_keys.remove(&new_slot);
855        self.slot_composed_generation.remove(&old_slot);
856        self.slot_composed_generation.remove(&new_slot);
857        self.policy.remove_content_type(old_slot);
858        if let Some(slots) = self.slot_compositions.remove(&old_slot) {
859            self.slot_compositions.insert(new_slot, slots);
860        }
861        if let Some(callback) = self.slot_callbacks.remove(&old_slot) {
862            self.slot_callbacks.insert(new_slot, callback);
863        }
864        if let Some(nodes) = self.precomposed_nodes.get_mut(&old_slot) {
865            let before_len = nodes.len();
866            nodes.retain(|candidate| *candidate != node_id);
867            let removed = before_len - nodes.len();
868            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
869            if nodes.is_empty() {
870                self.precomposed_nodes.remove(&old_slot);
871            }
872        }
873    }
874
875    /// Moves active slots starting from `start_index` to the reusable bucket.
876    /// Returns the list of node ids that were DISPOSED (not just moved to reusable).
877    /// Nodes that exceed max_reusable_per_type are disposed instead of cached.
878    pub fn dispose_or_reuse_starting_from_index(&mut self, start_index: usize) -> Vec<NodeId> {
879        if start_index >= self.active_order.len() {
880            return Vec::new();
881        }
882
883        let retain = self
884            .policy
885            .get_slots_to_retain(&self.active_order[start_index..]);
886        let mut retained = Vec::new();
887        while self.active_order.len() > start_index {
888            let Some(slot) = self.active_order.pop() else {
889                break;
890            };
891            if retain.contains(&slot) {
892                retained.push(slot);
893                continue;
894            }
895            self.move_slot_to_reusable(slot, false);
896        }
897        retained.reverse();
898        self.active_order.extend(retained);
899
900        self.enforce_reusable_pool_limits()
901    }
902
903    fn move_slot_to_reusable(&mut self, slot: SlotId, allow_exact_reactivation: bool) {
904        self.live_slots.remove(&slot);
905        self.current_pass_active_slots.remove(&slot);
906        self.mapping.deactivate_slot(slot);
907        let forgot_effects = self
908            .slot_compositions
909            .get(&slot)
910            .is_some_and(|host| host.forget_effects());
911        if allow_exact_reactivation && !forgot_effects {
912            self.exact_reactivation_slots.insert(slot);
913        } else {
914            self.exact_reactivation_slots.remove(&slot);
915        }
916
917        let content_type = self.slot_content_types.get(&slot).copied();
918        let nodes: SmallVec<[NodeId; 4]> = self
919            .mapping
920            .get_nodes(&slot)
921            .into_iter()
922            .flatten()
923            .copied()
924            .collect();
925        for node in nodes {
926            if let Some(ct) = content_type {
927                self.reusable_by_type
928                    .entry(ct)
929                    .or_default()
930                    .push_back((slot, node));
931            } else {
932                self.reusable_nodes_untyped.push_back((slot, node));
933            }
934            self.increment_reusable_slot(slot);
935        }
936    }
937
938    fn enforce_reusable_pool_limits(&mut self) -> Vec<NodeId> {
939        let mut disposed = Vec::new();
940        let mut typed_disposals = Vec::new();
941        for pool in self.reusable_by_type.values_mut() {
942            while pool.len() > self.max_reusable_per_type {
943                if let Some((slot, node_id)) = pool.pop_front() {
944                    typed_disposals.push((slot, node_id));
945                }
946            }
947        }
948        for (slot, node_id) in typed_disposals {
949            self.decrement_reusable_slot(slot);
950            self.mapping.remove_by_node(&node_id);
951            self.exact_reactivation_slots.remove(&slot);
952            disposed.push(node_id);
953        }
954
955        while self.reusable_nodes_untyped.len() > self.max_reusable_untyped {
956            if let Some((slot, node_id)) = self.reusable_nodes_untyped.pop_front() {
957                self.decrement_reusable_slot(slot);
958                self.mapping.remove_by_node(&node_id);
959                self.exact_reactivation_slots.remove(&slot);
960                disposed.push(node_id);
961            }
962        }
963
964        self.reusable_by_type.retain(|_, pool| !pool.is_empty());
965        disposed
966    }
967
968    /// Returns a snapshot of currently reusable nodes.
969    pub fn reusable(&self) -> Vec<NodeId> {
970        let mut nodes: Vec<NodeId> = self
971            .reusable_by_type
972            .values()
973            .flat_map(|pool| pool.iter().map(|(_, n)| *n))
974            .collect();
975        nodes.extend(self.reusable_nodes_untyped.iter().map(|(_, n)| *n));
976        nodes
977    }
978
979    /// Returns the number of slots currently active (in use during this pass).
980    ///
981    /// This reflects the slots that were activated via `register_active()` during
982    /// the current measurement pass.
983    pub fn active_slots_count(&self) -> usize {
984        self.active_order.len()
985    }
986
987    /// Returns the number of reusable slots in the pool.
988    ///
989    /// These are slots that were previously active but are now available for reuse
990    /// by compatible content types.
991    pub fn reusable_slots_count(&self) -> usize {
992        self.reusable_count
993    }
994
995    /// Invalidates all tracked subcomposition scopes.
996    ///
997    /// Hosts should call this when parent-captured inputs change without directly invalidating
998    /// the child scopes themselves. The next subcompose pass will then re-run active slot
999    /// content instead of skipping with stale captures.
1000    /// Invalidating scopes alone is not a reliable "recompose on next
1001    /// measure" signal: the recomposer drains invalid scopes before measure
1002    /// runs, re-executing the RETAINED slot callbacks — parent-captured
1003    /// values baked into a replaced closure never flow in, yet the scopes
1004    /// come back valid. The generation bump is the unlaunderable half: only
1005    /// a measure-time compose of the slot brings it current, so clean-slot
1006    /// reuse stays blocked until the new content actually landed.
1007    pub fn invalidate_scopes(&self) {
1008        self.mapping.invalidate_scopes();
1009        self.content_generation
1010            .set(self.content_generation.get() + 1);
1011    }
1012
1013    /// Advances the content generation without invalidating scopes: every
1014    /// slot must re-compose once before clean-slot reuse may skip it again.
1015    /// Called when the owning node leaves its parent — a retained subtree
1016    /// that comes back from the reuse pool restarts its effects only if its
1017    /// slots actually recompose, and scope flags carry no durable trace of
1018    /// the detach (deactivation walks stop at nested slot-host boundaries).
1019    pub fn bump_content_generation(&self) {
1020        self.content_generation
1021            .set(self.content_generation.get() + 1);
1022    }
1023
1024    /// Whether the slot last composed under the current content generation
1025    /// and owner-chain deactivation epoch. False after any
1026    /// [`Self::invalidate_scopes`], and false after any composition up the
1027    /// owner chain was deactivated, until a measure-time compose of this
1028    /// slot runs.
1029    pub fn slot_content_generation_current(&self, slot_id: SlotId, owner_epoch: u64) -> bool {
1030        self.slot_composed_generation.get(&slot_id).copied()
1031            == Some((self.content_generation.get(), owner_epoch))
1032    }
1033
1034    /// Records that the slot just composed under the current generation and
1035    /// the given owner-chain deactivation epoch.
1036    pub fn mark_slot_composed_current(&mut self, slot_id: SlotId, owner_epoch: u64) {
1037        self.slot_composed_generation
1038            .insert(slot_id, (self.content_generation.get(), owner_epoch));
1039    }
1040
1041    /// Returns whether the last slot registered via [`Self::register_active`] was reused.
1042    ///
1043    /// Returns `Some(true)` if the slot already existed (was reused from pool or
1044    /// was recomposed), `Some(false)` if it was newly created, or `None` if no
1045    /// slot has been registered yet this pass.
1046    ///
1047    /// This is useful for tracking composition statistics in lazy layouts.
1048    pub fn was_last_slot_reused(&self) -> Option<bool> {
1049        self.last_slot_reused
1050    }
1051
1052    #[doc(hidden)]
1053    pub fn debug_scope_ids_by_slot(&self) -> Vec<(u64, Vec<usize>)> {
1054        self.mapping
1055            .slot_to_scopes
1056            .iter()
1057            .map(|(slot, scopes)| (slot.raw(), scopes.iter().map(RecomposeScope::id).collect()))
1058            .collect()
1059    }
1060
1061    #[doc(hidden)]
1062    pub fn debug_slot_table_for_slot(&self, slot_id: SlotId) -> Option<Vec<crate::SlotDebugEntry>> {
1063        let slots = self.slot_compositions.get(&slot_id)?;
1064        Some(slots.borrow().debug_dump_slot_entries())
1065    }
1066
1067    #[doc(hidden)]
1068    pub fn debug_slot_table_groups_for_slot(&self, slot_id: SlotId) -> Option<Vec<DebugSlotGroup>> {
1069        let slots = self.slot_compositions.get(&slot_id)?;
1070        Some(slots.borrow().debug_dump_groups())
1071    }
1072
1073    /// Returns a snapshot of precomposed nodes.
1074    pub fn precomposed(&self) -> &HashMap<SlotId, Vec<NodeId>> {
1075        &self.precomposed_nodes
1076    }
1077
1078    /// Removes any precomposed nodes whose slots were not activated during the
1079    /// current pass and returns their identifiers for disposal.
1080    pub fn drain_inactive_precomposed(&mut self) -> Vec<NodeId> {
1081        let mut disposed = Vec::new();
1082        let mut empty_slots = Vec::new();
1083        for (slot, nodes) in self.precomposed_nodes.iter_mut() {
1084            if !self.current_pass_active_slots.contains(slot) {
1085                disposed.extend(nodes.iter().copied());
1086                empty_slots.push(*slot);
1087            }
1088        }
1089        for slot in empty_slots {
1090            self.precomposed_nodes.remove(&slot);
1091            self.prune_slot_if_unused(slot);
1092        }
1093        self.precomposed_count = self.precomposed_count.saturating_sub(disposed.len());
1094        disposed
1095    }
1096}
1097
1098#[cfg(test)]
1099#[path = "tests/subcompose_tests.rs"]
1100mod tests;