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