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        // 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 slot_has_inactive_scopes(&self, slot: SlotId) -> bool {
272        self.slot_to_scopes
273            .get(&slot)
274            .is_some_and(|scopes| scopes.iter().any(|scope| !scope.is_active()))
275    }
276
277    fn deactivate_slot(&self, slot: SlotId) {
278        if let Some(scopes) = self.slot_to_scopes.get(&slot) {
279            for scope in scopes {
280                scope.deactivate();
281            }
282        }
283    }
284
285    fn invalidate_scopes(&self) {
286        for scopes in self.slot_to_scopes.values() {
287            for scope in scopes {
288                scope.invalidate();
289            }
290        }
291    }
292}
293
294/// Tracks the state of nodes produced by subcomposition, enabling reuse between
295/// measurement passes.
296pub struct SubcomposeState {
297    mapping: NodeSlotMapping,
298    active_order: Vec<SlotId>,
299    live_slots: HashSet<SlotId>,
300    current_pass_active_slots: HashSet<SlotId>,
301    /// Per-content-type reusable node pools used to narrow compatible node lookup.
302    /// Key is content type, value is a deque of (SlotId, NodeId) pairs.
303    /// Nodes without content type go to `reusable_nodes_untyped`.
304    reusable_by_type: HashMap<u64, VecDeque<(SlotId, NodeId)>>,
305    /// Reusable nodes without a content type (fallback pool).
306    reusable_nodes_untyped: VecDeque<(SlotId, NodeId)>,
307    reusable_node_counts: HashMap<SlotId, usize>,
308    exact_reactivation_slots: HashSet<SlotId>,
309    /// Maps slot to its content type for efficient lookup during reuse.
310    slot_content_types: HashMap<SlotId, u64>,
311    precomposed_nodes: HashMap<SlotId, Vec<NodeId>>,
312    policy: Box<dyn SlotReusePolicy>,
313    pub(crate) current_index: usize,
314    pub(crate) reusable_count: usize,
315    pub(crate) precomposed_count: usize,
316    /// Per-slot SlotsHost for isolated compositions.
317    /// Each SlotId gets its own slot table, avoiding cursor-based conflicts
318    /// when items are subcomposed in different orders.
319    slot_compositions: HashMap<SlotId, Rc<SlotsHost>>,
320    /// Latest slot root callbacks keyed by slot id.
321    slot_callbacks: HashMap<SlotId, CallbackHolder>,
322    /// Maximum number of reusable slots to keep cached per content type.
323    max_reusable_per_type: usize,
324    /// Maximum number of reusable slots for the untyped pool.
325    max_reusable_untyped: usize,
326    /// Whether the last slot registered via register_active was reused.
327    /// Set during register_active, read via was_last_slot_reused().
328    last_slot_reused: Option<bool>,
329    /// Capture key stored at the last composition of each slot. A retained
330    /// slot may skip recomposition on a measure pass only while the caller
331    /// presents an equal key (see `retained_capture_key_matches`).
332    retained_capture_keys: HashMap<SlotId, RetainedCaptureKey>,
333    /// Bumped by `invalidate_scopes`; slots must re-compose once per bump
334    /// before clean-slot reuse may skip them again.
335    content_generation: std::cell::Cell<u64>,
336    /// The (content generation, owner-chain deactivation epoch) each slot
337    /// last composed under.
338    slot_composed_generation: HashMap<SlotId, (u64, u64)>,
339}
340
341/// Type-erased capture key recorded when a slot composes. Values that flow
342/// into slot content from the measure policy itself (a scaffold's computed
343/// padding, a box's constraints) never invalidate a recompose scope when they
344/// change, so slot reuse must compare them explicitly.
345struct RetainedCaptureKey {
346    value: Box<dyn Any>,
347    eq: fn(&dyn Any, &dyn Any) -> bool,
348}
349
350fn retained_capture_key_eq<K: PartialEq + 'static>(stored: &dyn Any, candidate: &dyn Any) -> bool {
351    match (stored.downcast_ref::<K>(), candidate.downcast_ref::<K>()) {
352        (Some(stored), Some(candidate)) => stored == candidate,
353        _ => false,
354    }
355}
356
357impl fmt::Debug for SubcomposeState {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        f.debug_struct("SubcomposeState")
360            .field("mapping", &self.mapping)
361            .field("active_order", &self.active_order)
362            .field("reusable_by_type_count", &self.reusable_by_type.len())
363            .field("reusable_untyped_count", &self.reusable_nodes_untyped.len())
364            .field("precomposed_nodes", &self.precomposed_nodes)
365            .field("current_index", &self.current_index)
366            .field("reusable_count", &self.reusable_count)
367            .field("precomposed_count", &self.precomposed_count)
368            .field("slot_compositions_count", &self.slot_compositions.len())
369            .finish()
370    }
371}
372
373impl Default for SubcomposeState {
374    fn default() -> Self {
375        Self::new(Box::new(DefaultSlotReusePolicy))
376    }
377}
378
379/// Default maximum reusable slots to cache per content type.
380/// With multiple content types, total reusable = this * number_of_types.
381const DEFAULT_MAX_REUSABLE_PER_TYPE: usize = 5;
382
383/// Default maximum reusable slots for the untyped pool.
384/// This is higher than per-type since all items without content types share this pool.
385/// Matches RecyclerView's default cache size.
386const DEFAULT_MAX_REUSABLE_UNTYPED: usize = 10;
387
388impl SubcomposeState {
389    /// Creates a new [`SubcomposeState`] using the supplied reuse policy.
390    pub fn new(policy: Box<dyn SlotReusePolicy>) -> Self {
391        Self {
392            mapping: NodeSlotMapping::default(),
393            active_order: Vec::new(),
394            live_slots: HashSet::default(),
395            current_pass_active_slots: HashSet::default(),
396            reusable_by_type: HashMap::default(),
397            reusable_nodes_untyped: VecDeque::new(),
398            reusable_node_counts: HashMap::default(),
399            exact_reactivation_slots: HashSet::default(),
400            slot_content_types: HashMap::default(),
401            precomposed_nodes: HashMap::default(),
402            policy,
403            current_index: 0,
404            reusable_count: 0,
405            precomposed_count: 0,
406            slot_compositions: HashMap::default(),
407            slot_callbacks: HashMap::default(),
408            max_reusable_per_type: DEFAULT_MAX_REUSABLE_PER_TYPE,
409            max_reusable_untyped: DEFAULT_MAX_REUSABLE_UNTYPED,
410            last_slot_reused: None,
411            retained_capture_keys: HashMap::default(),
412            content_generation: std::cell::Cell::new(0),
413            slot_composed_generation: HashMap::default(),
414        }
415    }
416
417    /// Sets the policy used for future reuse decisions.
418    pub fn set_policy(&mut self, policy: Box<dyn SlotReusePolicy>) {
419        self.policy = policy;
420    }
421
422    pub fn set_reusable_pool_limits(&mut self, per_type: usize, untyped: usize) {
423        self.max_reusable_per_type = per_type;
424        self.max_reusable_untyped = untyped;
425    }
426
427    /// Registers a content type for a slot.
428    ///
429    /// Stores the content type locally for efficient pool-based reuse lookup,
430    /// and also delegates to the policy for compatibility checking.
431    ///
432    /// Call this before subcomposing an item to enable content-type-aware slot reuse.
433    pub fn register_content_type(&mut self, slot_id: SlotId, content_type: u64) {
434        self.slot_content_types.insert(slot_id, content_type);
435        self.policy.register_content_type(slot_id, content_type);
436    }
437
438    /// Updates the content type for a slot, handling optional content types.
439    ///
440    /// If `content_type` is `Some(type)`, registers the type for the slot.
441    /// If `content_type` is `None`, removes any previously registered type.
442    /// This ensures stale types don't drive incorrect reuse.
443    pub fn update_content_type(&mut self, slot_id: SlotId, content_type: Option<u64>) {
444        match content_type {
445            Some(ct) => self.register_content_type(slot_id, ct),
446            None => {
447                self.slot_content_types.remove(&slot_id);
448                self.policy.remove_content_type(slot_id);
449            }
450        }
451    }
452
453    /// Returns the content type for a slot, if registered.
454    pub fn get_content_type(&self, slot_id: SlotId) -> Option<u64> {
455        self.slot_content_types.get(&slot_id).copied()
456    }
457
458    /// Starts a new subcompose pass.
459    ///
460    /// Call this before subcomposing the current frame so the state can
461    /// track which slots are active and dispose the inactive ones later.
462    pub fn begin_pass(&mut self) {
463        self.current_index = 0;
464        self.current_pass_active_slots.clear();
465    }
466
467    /// Returns the current active slot cursor for the in-progress pass.
468    pub fn active_slot_cursor(&self) -> usize {
469        self.current_index
470    }
471
472    /// Restores the active slot cursor for work that should not become part of
473    /// the rendered active set, such as lazy-list prefetch measurement.
474    pub fn restore_active_slot_cursor(&mut self, cursor: usize) {
475        self.current_index = cursor.min(self.active_order.len());
476    }
477
478    /// Moves an active slot out of the rendered set and into the reusable pool.
479    pub fn recycle_active_slot(&mut self, slot_id: SlotId) -> Vec<NodeId> {
480        self.recycle_active_slot_internal(slot_id, false)
481    }
482
483    pub fn recycle_prefetched_active_slot(&mut self, slot_id: SlotId) -> Vec<NodeId> {
484        self.recycle_active_slot_internal(slot_id, true)
485    }
486
487    pub fn recycle_active_slots_where(
488        &mut self,
489        mut predicate: impl FnMut(SlotId) -> bool,
490    ) -> Vec<NodeId> {
491        let slots: Vec<_> = self
492            .active_order
493            .iter()
494            .copied()
495            .filter(|slot| !self.current_pass_active_slots.contains(slot))
496            .filter(|slot| predicate(*slot))
497            .collect();
498        let mut disposed = Vec::new();
499        for slot in slots {
500            disposed.extend(self.recycle_active_slot(slot));
501        }
502        disposed
503    }
504
505    fn recycle_active_slot_internal(
506        &mut self,
507        slot_id: SlotId,
508        allow_exact_reactivation: bool,
509    ) -> Vec<NodeId> {
510        let Some(position) = self
511            .active_order
512            .iter()
513            .position(|candidate| *candidate == slot_id)
514        else {
515            return Vec::new();
516        };
517        self.active_order.remove(position);
518        if position < self.current_index {
519            self.current_index = self.current_index.saturating_sub(1);
520        }
521        self.move_slot_to_reusable(slot_id, allow_exact_reactivation);
522        self.enforce_reusable_pool_limits()
523    }
524
525    /// Finishes a subcompose pass, disposing slots that were not used.
526    pub fn finish_pass(&mut self) -> Vec<NodeId> {
527        self.dispose_or_reuse_starting_from_index(self.current_index)
528    }
529
530    /// Returns the SlotsHost for the given slot ID, creating a new one if it doesn't exist.
531    /// Each slot gets its own isolated slot table, avoiding cursor-based conflicts when
532    /// items are subcomposed in different orders.
533    pub fn get_or_create_slots(&mut self, slot_id: SlotId) -> Rc<SlotsHost> {
534        Rc::clone(
535            self.slot_compositions
536                .entry(slot_id)
537                .or_insert_with(|| Rc::new(SlotsHost::new(SlotTable::new()))),
538        )
539    }
540
541    /// Returns the latest callback holder for the given slot, creating one if needed.
542    pub fn callback_holder(&mut self, slot_id: SlotId) -> CallbackHolder {
543        self.slot_callbacks.entry(slot_id).or_default().clone()
544    }
545
546    #[doc(hidden)]
547    pub fn activate_current_active_slot(&mut self, slot_id: SlotId) -> Option<Vec<NodeId>> {
548        if self.current_pass_active_slots.contains(&slot_id)
549            || self.exact_reactivation_slots.contains(&slot_id)
550            || self.reusable_node_counts.contains_key(&slot_id)
551            || self
552                .active_order
553                .get(self.current_index)
554                .copied()
555                .is_none_or(|active_slot| active_slot != slot_id)
556            || self.mapping.slot_has_invalid_scopes(slot_id)
557            || self.mapping.slot_has_inactive_scopes(slot_id)
558        {
559            return None;
560        }
561
562        let nodes = self.mapping.get_nodes(&slot_id)?.to_vec();
563        self.last_slot_reused = Some(true);
564        self.live_slots.insert(slot_id);
565        self.current_pass_active_slots.insert(slot_id);
566        self.current_index += 1;
567        Some(nodes)
568    }
569
570    /// Whether precomposed nodes are waiting to be consumed for this slot.
571    /// A pending precomposition means the retained mapping is about to be
572    /// superseded, so clean-slot reuse must reject and compose.
573    pub fn has_pending_precompositions(&self, slot_id: SlotId) -> bool {
574        self.precomposed_nodes.contains_key(&slot_id)
575    }
576
577    /// Whether the slot's stored capture key equals `key`. A missing key never
578    /// matches: a slot must compose at least once under the key discipline
579    /// before it may skip.
580    pub fn retained_capture_key_matches<K: PartialEq + 'static>(
581        &self,
582        slot_id: SlotId,
583        key: &K,
584    ) -> bool {
585        self.retained_capture_keys
586            .get(&slot_id)
587            .is_some_and(|retained| (retained.eq)(retained.value.as_ref(), key))
588    }
589
590    /// Records the capture key the slot is being composed under.
591    pub fn store_retained_capture_key<K: PartialEq + 'static>(&mut self, slot_id: SlotId, key: K) {
592        self.retained_capture_keys.insert(
593            slot_id,
594            RetainedCaptureKey {
595                value: Box::new(key),
596                eq: retained_capture_key_eq::<K>,
597            },
598        );
599    }
600
601    #[doc(hidden)]
602    pub fn take_exact_slot_activation(&mut self, slot_id: SlotId) -> Option<ExactSlotActivation> {
603        let active_position = self
604            .active_order
605            .iter()
606            .position(|candidate| *candidate == slot_id);
607        let is_exact_reactivation = self.exact_reactivation_slots.contains(&slot_id);
608        if active_position.is_none() && !is_exact_reactivation
609            || self.mapping.slot_has_invalid_scopes(slot_id)
610        {
611            return None;
612        }
613
614        let nodes = self.mapping.get_nodes(&slot_id)?.to_vec();
615        let scopes = self
616            .mapping
617            .get_scopes(&slot_id)
618            .unwrap_or_default()
619            .to_vec();
620        if active_position.is_none() {
621            for node in &nodes {
622                let _ = self.remove_from_reusable_pools(*node);
623            }
624        }
625        self.exact_reactivation_slots.remove(&slot_id);
626        Some(ExactSlotActivation {
627            nodes,
628            scopes,
629            reactivate_scopes: active_position.is_none(),
630        })
631    }
632
633    #[doc(hidden)]
634    pub fn take_exact_slot_for_activation(
635        &mut self,
636        slot_id: SlotId,
637    ) -> Option<(Vec<NodeId>, Vec<RecomposeScope>)> {
638        self.take_exact_slot_activation(slot_id)
639            .map(|activation| (activation.nodes, activation.scopes))
640    }
641
642    /// Records that the nodes in `node_ids` are currently rendering the provided
643    /// `slot_id`.
644    pub fn register_active(
645        &mut self,
646        slot_id: SlotId,
647        node_ids: &[NodeId],
648        scopes: &[RecomposeScope],
649    ) {
650        self.register_active_with_scope_reactivation(slot_id, node_ids, scopes, true);
651    }
652
653    #[doc(hidden)]
654    pub fn register_active_with_scope_reactivation(
655        &mut self,
656        slot_id: SlotId,
657        node_ids: &[NodeId],
658        scopes: &[RecomposeScope],
659        reactivate_scopes: bool,
660    ) {
661        let was_reused = self.mapping.get_nodes(&slot_id).is_some();
662        self.last_slot_reused = Some(was_reused);
663        self.live_slots.insert(slot_id);
664        self.current_pass_active_slots.insert(slot_id);
665        self.exact_reactivation_slots.remove(&slot_id);
666
667        if let Some(position) = self.active_order.iter().position(|slot| *slot == slot_id) {
668            if position < self.current_index {
669                if reactivate_scopes {
670                    for scope in scopes {
671                        scope.reactivate();
672                    }
673                }
674                self.update_active_slot_mapping(slot_id, node_ids, scopes);
675                return;
676            }
677            self.active_order.remove(position);
678        }
679        if reactivate_scopes {
680            for scope in scopes {
681                scope.reactivate();
682            }
683        }
684        self.update_active_slot_mapping(slot_id, node_ids, scopes);
685        let insert_at = self.current_index.min(self.active_order.len());
686        self.active_order.insert(insert_at, slot_id);
687        self.current_index += 1;
688    }
689
690    fn update_active_slot_mapping(
691        &mut self,
692        slot_id: SlotId,
693        node_ids: &[NodeId],
694        scopes: &[RecomposeScope],
695    ) {
696        self.mapping.set_nodes(slot_id, node_ids);
697        self.mapping.set_scopes(slot_id, scopes);
698        if let Some(nodes) = self.precomposed_nodes.get_mut(&slot_id) {
699            let before_len = nodes.len();
700            nodes.retain(|node| !node_ids.contains(node));
701            let removed = before_len - nodes.len();
702            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
703            if nodes.is_empty() {
704                self.precomposed_nodes.remove(&slot_id);
705            }
706        }
707    }
708
709    /// Stores a precomposed node for the provided slot. Precomposed nodes stay
710    /// detached from the tree until they are activated by `register_active`.
711    pub fn register_precomposed(&mut self, slot_id: SlotId, node_id: NodeId) {
712        self.precomposed_nodes
713            .entry(slot_id)
714            .or_default()
715            .push(node_id);
716        self.precomposed_count += 1;
717    }
718
719    fn increment_reusable_slot(&mut self, slot_id: SlotId) {
720        *self.reusable_node_counts.entry(slot_id).or_insert(0) += 1;
721        self.reusable_count += 1;
722    }
723
724    fn decrement_reusable_slot(&mut self, slot_id: SlotId) {
725        let Some(entry) = self.reusable_node_counts.get_mut(&slot_id) else {
726            self.reusable_count = self.reusable_count.saturating_sub(1);
727            return;
728        };
729        *entry = entry.saturating_sub(1);
730        if *entry == 0 {
731            self.reusable_node_counts.remove(&slot_id);
732        }
733        self.reusable_count = self.reusable_count.saturating_sub(1);
734    }
735
736    fn prune_slot_if_unused(&mut self, slot_id: SlotId) {
737        if self.live_slots.contains(&slot_id) || self.reusable_node_counts.contains_key(&slot_id) {
738            return;
739        }
740
741        debug_assert!(
742            self.mapping.get_nodes(&slot_id).is_none(),
743            "inactive slot {slot_id:?} still has mapped nodes",
744        );
745
746        self.slot_compositions.remove(&slot_id);
747        self.slot_callbacks.remove(&slot_id);
748        self.slot_content_types.remove(&slot_id);
749        self.retained_capture_keys.remove(&slot_id);
750        self.slot_composed_generation.remove(&slot_id);
751        self.policy.remove_content_type(slot_id);
752        if let Some(nodes) = self.precomposed_nodes.remove(&slot_id) {
753            self.precomposed_count = self.precomposed_count.saturating_sub(nodes.len());
754        }
755    }
756
757    /// Returns the node that previously rendered this slot, if it is still
758    /// considered reusable, and whether it was REBOUND — taken from another
759    /// slot, so its retained subtree is about to show different content. An
760    /// exact-slot reactivation hands the same item its own subtree back and
761    /// is not a rebinding.
762    ///
763    /// Lookup order:
764    /// 1. Exact slot match in the appropriate pool (not a rebinding)
765    /// 2. Any policy-compatible node from the same content-type pool
766    /// 3. Fallback to untyped pool with policy compatibility check
767    pub fn take_node_from_reusables(&mut self, slot_id: SlotId) -> Option<(NodeId, bool)> {
768        if let Some(nodes) = self.mapping.get_nodes(&slot_id) {
769            let first_node = nodes.first().copied();
770            if let Some(node_id) = first_node {
771                let _ = self.remove_from_reusable_pools(node_id);
772                self.exact_reactivation_slots.remove(&slot_id);
773                return Some((node_id, false));
774            }
775        }
776
777        let content_type = self.slot_content_types.get(&slot_id).copied();
778
779        if let Some(ct) = content_type
780            && let Some((old_slot, node_id)) = self.take_compatible_typed_reusable(ct, slot_id)
781        {
782            self.decrement_reusable_slot(old_slot);
783            self.move_node_to_slot(node_id, old_slot, slot_id);
784            return Some((node_id, true));
785        }
786
787        let exact_reactivation_slots = &self.exact_reactivation_slots;
788        let policy = &self.policy;
789        let position = self
790            .reusable_nodes_untyped
791            .iter()
792            .position(|(existing_slot, _)| {
793                !exact_reactivation_slots.contains(existing_slot)
794                    && policy.are_compatible(*existing_slot, slot_id)
795            });
796
797        if let Some(index) = position
798            && let Some((old_slot, node_id)) = self.reusable_nodes_untyped.remove(index)
799        {
800            self.decrement_reusable_slot(old_slot);
801            self.move_node_to_slot(node_id, old_slot, slot_id);
802            return Some((node_id, true));
803        }
804
805        None
806    }
807
808    fn take_compatible_typed_reusable(
809        &mut self,
810        content_type: u64,
811        requested_slot: SlotId,
812    ) -> Option<(SlotId, NodeId)> {
813        let reused = {
814            let policy = &self.policy;
815            let exact_reactivation_slots = &self.exact_reactivation_slots;
816            let pool = self.reusable_by_type.get_mut(&content_type)?;
817            let index = pool.iter().position(|(existing_slot, _)| {
818                !exact_reactivation_slots.contains(existing_slot)
819                    && policy.are_compatible(*existing_slot, requested_slot)
820            })?;
821            pool.remove(index)
822        };
823
824        if self
825            .reusable_by_type
826            .get(&content_type)
827            .map(|pool| pool.is_empty())
828            .unwrap_or(false)
829        {
830            self.reusable_by_type.remove(&content_type);
831        }
832
833        reused
834    }
835
836    /// Removes a node from whatever reusable pool it's in.
837    fn remove_from_reusable_pools(&mut self, node_id: NodeId) -> Option<SlotId> {
838        // Check typed pools
839        let mut typed_match = None;
840        for (&content_type, pool) in &self.reusable_by_type {
841            if let Some(position) = pool
842                .iter()
843                .position(|(_, pooled_node)| *pooled_node == node_id)
844            {
845                typed_match = Some((content_type, position));
846                break;
847            }
848        }
849        if let Some((content_type, position)) = typed_match {
850            let pool = self.reusable_by_type.get_mut(&content_type)?;
851            let (slot, _) = pool.remove(position)?;
852            if pool.is_empty() {
853                self.reusable_by_type.remove(&content_type);
854            }
855            self.decrement_reusable_slot(slot);
856            return Some(slot);
857        }
858        // Check untyped pool
859        if let Some(position) = self
860            .reusable_nodes_untyped
861            .iter()
862            .position(|(_, n)| *n == node_id)
863            && let Some((slot, _)) = self.reusable_nodes_untyped.remove(position)
864        {
865            self.decrement_reusable_slot(slot);
866            return Some(slot);
867        }
868        None
869    }
870
871    /// Moves a node from one slot to another, updating mappings.
872    fn move_node_to_slot(&mut self, node_id: NodeId, old_slot: SlotId, new_slot: SlotId) {
873        if old_slot == new_slot {
874            return;
875        }
876
877        self.mapping.remove_by_node(&node_id);
878        self.exact_reactivation_slots.remove(&old_slot);
879        self.mapping.add_node(new_slot, node_id);
880        self.slot_content_types.remove(&old_slot);
881        self.retained_capture_keys.remove(&old_slot);
882        self.retained_capture_keys.remove(&new_slot);
883        self.slot_composed_generation.remove(&old_slot);
884        self.slot_composed_generation.remove(&new_slot);
885        self.policy.remove_content_type(old_slot);
886        if let Some(slots) = self.slot_compositions.remove(&old_slot) {
887            self.slot_compositions.insert(new_slot, slots);
888        }
889        if let Some(callback) = self.slot_callbacks.remove(&old_slot) {
890            self.slot_callbacks.insert(new_slot, callback);
891        }
892        if let Some(nodes) = self.precomposed_nodes.get_mut(&old_slot) {
893            let before_len = nodes.len();
894            nodes.retain(|candidate| *candidate != node_id);
895            let removed = before_len - nodes.len();
896            self.precomposed_count = self.precomposed_count.saturating_sub(removed);
897            if nodes.is_empty() {
898                self.precomposed_nodes.remove(&old_slot);
899            }
900        }
901    }
902
903    /// Moves active slots starting from `start_index` to the reusable bucket.
904    /// Returns the list of node ids that were DISPOSED (not just moved to reusable).
905    /// Nodes that exceed max_reusable_per_type are disposed instead of cached.
906    pub fn dispose_or_reuse_starting_from_index(&mut self, start_index: usize) -> Vec<NodeId> {
907        if start_index >= self.active_order.len() {
908            return Vec::new();
909        }
910
911        let retain = self
912            .policy
913            .get_slots_to_retain(&self.active_order[start_index..]);
914        let mut retained = Vec::new();
915        while self.active_order.len() > start_index {
916            let Some(slot) = self.active_order.pop() else {
917                break;
918            };
919            if retain.contains(&slot) {
920                retained.push(slot);
921                continue;
922            }
923            self.move_slot_to_reusable(slot, false);
924        }
925        retained.reverse();
926        self.active_order.extend(retained);
927
928        self.enforce_reusable_pool_limits()
929    }
930
931    fn move_slot_to_reusable(&mut self, slot: SlotId, allow_exact_reactivation: bool) {
932        self.live_slots.remove(&slot);
933        self.current_pass_active_slots.remove(&slot);
934        self.mapping.deactivate_slot(slot);
935        let forgot_effects = self
936            .slot_compositions
937            .get(&slot)
938            .is_some_and(|host| host.forget_effects());
939        if allow_exact_reactivation && !forgot_effects {
940            self.exact_reactivation_slots.insert(slot);
941        } else {
942            self.exact_reactivation_slots.remove(&slot);
943        }
944
945        let content_type = self.slot_content_types.get(&slot).copied();
946        let nodes: SmallVec<[NodeId; 4]> = self
947            .mapping
948            .get_nodes(&slot)
949            .into_iter()
950            .flatten()
951            .copied()
952            .collect();
953        for node in nodes {
954            if let Some(ct) = content_type {
955                self.reusable_by_type
956                    .entry(ct)
957                    .or_default()
958                    .push_back((slot, node));
959            } else {
960                self.reusable_nodes_untyped.push_back((slot, node));
961            }
962            self.increment_reusable_slot(slot);
963        }
964    }
965
966    fn enforce_reusable_pool_limits(&mut self) -> Vec<NodeId> {
967        let mut disposed = Vec::new();
968        let mut typed_disposals = Vec::new();
969        for pool in self.reusable_by_type.values_mut() {
970            while pool.len() > self.max_reusable_per_type {
971                if let Some((slot, node_id)) = pool.pop_front() {
972                    typed_disposals.push((slot, node_id));
973                }
974            }
975        }
976        for (slot, node_id) in typed_disposals {
977            self.decrement_reusable_slot(slot);
978            self.mapping.remove_by_node(&node_id);
979            self.exact_reactivation_slots.remove(&slot);
980            disposed.push(node_id);
981        }
982
983        // Enforce limit on untyped pool (uses separate, larger limit)
984        while self.reusable_nodes_untyped.len() > self.max_reusable_untyped {
985            if let Some((slot, node_id)) = self.reusable_nodes_untyped.pop_front() {
986                self.decrement_reusable_slot(slot);
987                self.mapping.remove_by_node(&node_id);
988                self.exact_reactivation_slots.remove(&slot);
989                disposed.push(node_id);
990            }
991        }
992
993        self.reusable_by_type.retain(|_, pool| !pool.is_empty());
994        disposed
995    }
996
997    /// Returns a snapshot of currently reusable nodes.
998    pub fn reusable(&self) -> Vec<NodeId> {
999        let mut nodes: Vec<NodeId> = self
1000            .reusable_by_type
1001            .values()
1002            .flat_map(|pool| pool.iter().map(|(_, n)| *n))
1003            .collect();
1004        nodes.extend(self.reusable_nodes_untyped.iter().map(|(_, n)| *n));
1005        nodes
1006    }
1007
1008    /// Returns the number of slots currently active (in use during this pass).
1009    ///
1010    /// This reflects the slots that were activated via `register_active()` during
1011    /// the current measurement pass.
1012    pub fn active_slots_count(&self) -> usize {
1013        self.active_order.len()
1014    }
1015
1016    /// Returns the number of reusable slots in the pool.
1017    ///
1018    /// These are slots that were previously active but are now available for reuse
1019    /// by compatible content types.
1020    pub fn reusable_slots_count(&self) -> usize {
1021        self.reusable_count
1022    }
1023
1024    /// Invalidates all tracked subcomposition scopes.
1025    ///
1026    /// Hosts should call this when parent-captured inputs change without directly invalidating
1027    /// the child scopes themselves. The next subcompose pass will then re-run active slot
1028    /// content instead of skipping with stale captures.
1029    /// Invalidating scopes alone is not a reliable "recompose on next
1030    /// measure" signal: the recomposer drains invalid scopes before measure
1031    /// runs, re-executing the RETAINED slot callbacks — parent-captured
1032    /// values baked into a replaced closure never flow in, yet the scopes
1033    /// come back valid. The generation bump is the unlaunderable half: only
1034    /// a measure-time compose of the slot brings it current, so clean-slot
1035    /// reuse stays blocked until the new content actually landed.
1036    pub fn invalidate_scopes(&self) {
1037        self.mapping.invalidate_scopes();
1038        self.content_generation
1039            .set(self.content_generation.get() + 1);
1040    }
1041
1042    /// Advances the content generation without invalidating scopes: every
1043    /// slot must re-compose once before clean-slot reuse may skip it again.
1044    /// Called when the owning node leaves its parent — a retained subtree
1045    /// that comes back from the reuse pool restarts its effects only if its
1046    /// slots actually recompose, and scope flags carry no durable trace of
1047    /// the detach (deactivation walks stop at nested slot-host boundaries).
1048    pub fn bump_content_generation(&self) {
1049        self.content_generation
1050            .set(self.content_generation.get() + 1);
1051    }
1052
1053    /// Whether the slot last composed under the current content generation
1054    /// and owner-chain deactivation epoch. False after any
1055    /// [`Self::invalidate_scopes`], and false after any composition up the
1056    /// owner chain was deactivated, until a measure-time compose of this
1057    /// slot runs.
1058    pub fn slot_content_generation_current(&self, slot_id: SlotId, owner_epoch: u64) -> bool {
1059        self.slot_composed_generation.get(&slot_id).copied()
1060            == Some((self.content_generation.get(), owner_epoch))
1061    }
1062
1063    /// Records that the slot just composed under the current generation and
1064    /// the given owner-chain deactivation epoch.
1065    pub fn mark_slot_composed_current(&mut self, slot_id: SlotId, owner_epoch: u64) {
1066        self.slot_composed_generation
1067            .insert(slot_id, (self.content_generation.get(), owner_epoch));
1068    }
1069
1070    /// Returns whether the last slot registered via [`Self::register_active`] was reused.
1071    ///
1072    /// Returns `Some(true)` if the slot already existed (was reused from pool or
1073    /// was recomposed), `Some(false)` if it was newly created, or `None` if no
1074    /// slot has been registered yet this pass.
1075    ///
1076    /// This is useful for tracking composition statistics in lazy layouts.
1077    pub fn was_last_slot_reused(&self) -> Option<bool> {
1078        self.last_slot_reused
1079    }
1080
1081    #[doc(hidden)]
1082    pub fn debug_scope_ids_by_slot(&self) -> Vec<(u64, Vec<usize>)> {
1083        self.mapping
1084            .slot_to_scopes
1085            .iter()
1086            .map(|(slot, scopes)| (slot.raw(), scopes.iter().map(RecomposeScope::id).collect()))
1087            .collect()
1088    }
1089
1090    #[doc(hidden)]
1091    pub fn debug_slot_table_for_slot(&self, slot_id: SlotId) -> Option<Vec<crate::SlotDebugEntry>> {
1092        let slots = self.slot_compositions.get(&slot_id)?;
1093        Some(slots.borrow().debug_dump_slot_entries())
1094    }
1095
1096    #[doc(hidden)]
1097    pub fn debug_slot_table_groups_for_slot(&self, slot_id: SlotId) -> Option<Vec<DebugSlotGroup>> {
1098        let slots = self.slot_compositions.get(&slot_id)?;
1099        Some(slots.borrow().debug_dump_groups())
1100    }
1101
1102    /// Returns a snapshot of precomposed nodes.
1103    pub fn precomposed(&self) -> &HashMap<SlotId, Vec<NodeId>> {
1104        &self.precomposed_nodes
1105    }
1106
1107    /// Removes any precomposed nodes whose slots were not activated during the
1108    /// current pass and returns their identifiers for disposal.
1109    pub fn drain_inactive_precomposed(&mut self) -> Vec<NodeId> {
1110        let mut disposed = Vec::new();
1111        let mut empty_slots = Vec::new();
1112        for (slot, nodes) in self.precomposed_nodes.iter_mut() {
1113            if !self.current_pass_active_slots.contains(slot) {
1114                disposed.extend(nodes.iter().copied());
1115                empty_slots.push(*slot);
1116            }
1117        }
1118        for slot in empty_slots {
1119            self.precomposed_nodes.remove(&slot);
1120            self.prune_slot_if_unused(slot);
1121        }
1122        // disposed.len() is the exact count of nodes removed
1123        self.precomposed_count = self.precomposed_count.saturating_sub(disposed.len());
1124        disposed
1125    }
1126}
1127
1128#[cfg(test)]
1129#[path = "tests/subcompose_tests.rs"]
1130mod tests;