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