Skip to main content

cranpose_foundation/lazy/
lazy_list_state.rs

1//! Lazy list state management.
2//!
3//! Provides [`LazyListState`] for controlling and observing lazy list scroll position.
4//!
5//! Design follows Jetpack Compose's LazyListState/LazyListScrollPosition pattern:
6//! - Reactive properties are backed by `MutableState<T>`:
7//!   - `first_visible_item_index`, `first_visible_item_scroll_offset`
8//!   - `can_scroll_forward`, `can_scroll_backward`
9//!   - `stats` (items_in_use, items_in_pool)
10//! - Non-reactive internals (caches, callbacks, prefetch, diagnostic counters) are in inner state
11
12use std::cell::RefCell;
13use std::rc::Rc;
14use std::sync::OnceLock;
15
16use cranpose_core::{MutableState, NodeId};
17use cranpose_macros::composable;
18
19use super::nearest_range::NearestRangeState;
20use super::prefetch::{PrefetchScheduler, PrefetchStrategy};
21
22static LAZY_MEASURE_TELEMETRY_ENABLED: OnceLock<bool> = OnceLock::new();
23
24fn lazy_measure_telemetry_enabled() -> bool {
25    *LAZY_MEASURE_TELEMETRY_ENABLED
26        .get_or_init(|| std::env::var_os("CRANPOSE_LAZY_MEASURE_TELEMETRY").is_some())
27}
28
29const MAX_PENDING_SCROLL_DELTA: f32 = 2000.0;
30const ITEM_SIZE_CACHE_CAPACITY: usize = 100;
31
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub(crate) struct LazyListMeasureStateSnapshot {
34    pub(crate) first_visible_item_index: usize,
35    pub(crate) first_visible_item_scroll_offset: f32,
36    pub(crate) pending_scroll_delta: f32,
37    pub(crate) pending_scroll_to: Option<(usize, f32)>,
38    pub(crate) average_item_size: f32,
39}
40
41/// Statistics about lazy layout item lifecycle.
42///
43/// Used for testing and debugging virtualization behavior.
44#[derive(Clone, Debug, Default, PartialEq)]
45pub struct LazyLayoutStats {
46    /// Number of items currently composed and visible.
47    pub items_in_use: usize,
48
49    /// Number of items in the recycle pool (available for reuse).
50    pub items_in_pool: usize,
51
52    /// Total number of items that have been composed.
53    pub total_composed: usize,
54
55    /// Number of items that were reused instead of newly composed.
56    pub reuse_count: usize,
57}
58
59// ─────────────────────────────────────────────────────────────────────────────
60// LazyListScrollPosition - Reactive scroll position (matches JC design)
61// ─────────────────────────────────────────────────────────────────────────────
62
63/// Contains the current scroll position represented by the first visible item
64/// index and the first visible item scroll offset.
65///
66/// This is a `Copy` type that holds reactive state. Reading `index` or `scroll_offset`
67/// during composition creates a snapshot dependency for automatic recomposition.
68///
69/// Matches Jetpack Compose's `LazyListScrollPosition` design.
70#[derive(Clone, Copy)]
71pub struct LazyListScrollPosition {
72    /// The index of the first visible item (reactive).
73    index: MutableState<usize>,
74    /// The scroll offset of the first visible item (reactive).
75    scroll_offset: MutableState<f32>,
76    /// Non-reactive internal state (key tracking, nearest range).
77    inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
78}
79
80/// Non-reactive internal state for scroll position.
81struct ScrollPositionInner {
82    /// The last known key of the item at index position.
83    /// Used for scroll position stability across data changes.
84    last_known_first_item_key: Option<u64>,
85    /// Sliding window range for optimized key lookups.
86    nearest_range_state: NearestRangeState,
87}
88
89impl LazyListScrollPosition {
90    fn is_alive(&self) -> bool {
91        self.index.is_alive() && self.scroll_offset.is_alive() && self.inner.is_alive()
92    }
93
94    fn current_index(&self) -> usize {
95        self.index.try_value().unwrap_or(0)
96    }
97
98    fn current_scroll_offset(&self) -> f32 {
99        self.scroll_offset.try_value().unwrap_or(0.0)
100    }
101
102    /// Returns the index of the first visible item (reactive read).
103    pub fn index(&self) -> usize {
104        if !self.index.is_alive() {
105            return 0;
106        }
107        self.index.get()
108    }
109
110    /// Returns the scroll offset of the first visible item (reactive read).
111    pub fn scroll_offset(&self) -> f32 {
112        if !self.scroll_offset.is_alive() {
113            return 0.0;
114        }
115        self.scroll_offset.get()
116    }
117
118    /// Updates the scroll position from a measurement result.
119    ///
120    /// Called after layout measurement to update the reactive scroll position.
121    /// This stores the key for scroll position stability and updates the nearest range.
122    pub(crate) fn update_from_measure_result(
123        &self,
124        first_visible_index: usize,
125        first_visible_scroll_offset: f32,
126        first_visible_item_key: Option<u64>,
127    ) {
128        if !self.is_alive() {
129            return;
130        }
131        // Update internal state (key tracking, nearest range)
132        self.inner.with(|rc| {
133            let mut inner = rc.borrow_mut();
134            inner.last_known_first_item_key = first_visible_item_key;
135            inner.nearest_range_state.update(first_visible_index);
136        });
137
138        // Only update reactive state if value changed (avoids recomposition loops)
139        let old_index = self.index.get_non_reactive();
140        if old_index != first_visible_index {
141            self.index.set(first_visible_index);
142        }
143        let old_offset = self.scroll_offset.get_non_reactive();
144        if (old_offset - first_visible_scroll_offset).abs() > 0.001 {
145            self.scroll_offset.set(first_visible_scroll_offset);
146        }
147    }
148
149    /// Requests a new position and clears the last known key.
150    /// Used for programmatic scrolls (scroll_to_item).
151    pub(crate) fn request_position_and_forget_last_known_key(
152        &self,
153        index: usize,
154        scroll_offset: f32,
155    ) {
156        if !self.is_alive() {
157            return;
158        }
159        // Update reactive state
160        if self.index.get_non_reactive() != index {
161            self.index.set(index);
162        }
163        if (self.scroll_offset.get_non_reactive() - scroll_offset).abs() > 0.001 {
164            self.scroll_offset.set(scroll_offset);
165        }
166        // Clear key and update nearest range
167        self.inner.with(|rc| {
168            let mut inner = rc.borrow_mut();
169            inner.last_known_first_item_key = None;
170            inner.nearest_range_state.update(index);
171        });
172    }
173
174    /// Adjusts scroll position if the first visible item was moved.
175    /// Returns the adjusted index.
176    pub(crate) fn update_if_first_item_moved<F>(
177        &self,
178        new_item_count: usize,
179        find_by_key: F,
180    ) -> usize
181    where
182        F: Fn(u64) -> Option<usize>,
183    {
184        if !self.index.is_alive() || !self.inner.is_alive() {
185            return 0;
186        }
187
188        let current_index = self.index.get_non_reactive();
189        let last_key = self
190            .inner
191            .try_with(|rc| rc.borrow().last_known_first_item_key)
192            .flatten();
193
194        let new_index = match last_key {
195            None => current_index.min(new_item_count.saturating_sub(1)),
196            Some(key) => find_by_key(key)
197                .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
198        };
199
200        if current_index != new_index {
201            self.index.set(new_index);
202            self.inner.with(|rc| {
203                rc.borrow_mut().nearest_range_state.update(new_index);
204            });
205        }
206        new_index
207    }
208
209    /// Returns the nearest range for optimized key lookups.
210    pub fn nearest_range(&self) -> std::ops::Range<usize> {
211        self.inner
212            .try_with(|rc| rc.borrow().nearest_range_state.range())
213            .unwrap_or(0..0)
214    }
215}
216
217// ─────────────────────────────────────────────────────────────────────────────
218// LazyListState - Main state object
219// ─────────────────────────────────────────────────────────────────────────────
220
221/// State object for lazy list scroll position tracking.
222///
223/// Holds the current scroll position and provides methods to programmatically
224/// control scrolling. Create with [`remember_lazy_list_state()`] in composition.
225///
226/// This type is `Copy`, so it can be passed to multiple closures without explicit `.clone()` calls.
227///
228/// # Reactive Properties (read during composition triggers recomposition)
229/// - `first_visible_item_index()` - index of first visible item
230/// - `first_visible_item_scroll_offset()` - scroll offset within first item
231/// - `can_scroll_forward()` - whether more items exist below/right
232/// - `can_scroll_backward()` - whether more items exist above/left
233/// - `stats()` - lifecycle statistics (`items_in_use`, `items_in_pool`)
234///
235/// # Non-Reactive Properties
236/// - `stats().total_composed` - total items composed (diagnostic)
237/// - `stats().reuse_count` - items reused from pool (diagnostic)
238/// - `layout_info()` - detailed layout information
239///
240/// # Example
241///
242/// ```rust,ignore
243/// let state = remember_lazy_list_state();
244///
245/// // Scroll to item 50
246/// state.scroll_to_item(50, 0.0);
247///
248/// // Get current visible item (reactive read)
249/// println!("First visible: {}", state.first_visible_item_index());
250/// ```
251#[derive(Clone, Copy)]
252pub struct LazyListState {
253    /// Scroll position with reactive index and offset (matches JC design).
254    scroll_position: LazyListScrollPosition,
255    /// Whether we can scroll forward (reactive, matches JC).
256    can_scroll_forward_state: MutableState<bool>,
257    /// Whether we can scroll backward (reactive, matches JC).
258    can_scroll_backward_state: MutableState<bool>,
259    /// Reactive stats state for triggering recomposition when stats change.
260    /// Only contains items_in_use and items_in_pool (diagnostic counters are in inner).
261    stats_state: MutableState<LazyLayoutStats>,
262    /// Non-reactive internal state (caches, callbacks, prefetch, layout info).
263    inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
264}
265
266// Implement PartialEq by comparing the stable inner state handle identity.
267// This allows LazyListState to be used as a composable function parameter
268// without dereferencing released state cells during parameter updates.
269impl PartialEq for LazyListState {
270    fn eq(&self, other: &Self) -> bool {
271        self.inner == other.inner
272    }
273}
274
275/// Non-reactive internal state for LazyListState.
276struct LazyListStateInner {
277    /// Scroll delta to be consumed in the next layout pass.
278    scroll_to_be_consumed: f32,
279
280    /// Pending scroll-to-item request.
281    pending_scroll_to_index: Option<(usize, f32)>,
282
283    /// Layout info from the last measure pass.
284    layout_info: LazyListLayoutInfo,
285
286    /// Invalidation callbacks.
287    invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
288    next_callback_id: u64,
289
290    /// Registered layout invalidation callback id, if any.
291    /// Used to prevent duplicate registrations on recomposition and to
292    /// allow clean re-registration after a branch is disposed and restored.
293    layout_invalidation_callback_id: Option<u64>,
294    layout_invalidation_node_id: Option<NodeId>,
295
296    /// Diagnostic counters (non-reactive - not typically displayed in UI).
297    total_composed: usize,
298    reuse_count: usize,
299
300    /// Cache of recently measured item sizes (index -> main_axis_size).
301    item_size_cache: std::collections::HashMap<usize, f32>,
302    item_size_lru: std::collections::VecDeque<usize>,
303
304    /// Running average of measured item sizes for estimation.
305    average_item_size: f32,
306    total_measured_items: usize,
307
308    /// Prefetch scheduler for pre-composing items.
309    prefetch_scheduler: PrefetchScheduler,
310
311    /// Prefetch strategy configuration.
312    prefetch_strategy: PrefetchStrategy,
313
314    /// Last scroll delta direction for prefetch.
315    last_scroll_direction: f32,
316}
317
318/// Creates a remembered [`LazyListState`] with default initial position.
319///
320/// This is the recommended way to create a `LazyListState` in composition.
321/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
322///
323/// # Example
324///
325/// ```rust,ignore
326/// let list_state = remember_lazy_list_state();
327///
328/// // Pass to multiple closures - no .clone() needed!
329/// LazyColumn(modifier, list_state, spec, content);
330/// Button(move || list_state.scroll_to_item(0, 0.0));
331/// ```
332#[composable]
333pub fn remember_lazy_list_state() -> LazyListState {
334    remember_lazy_list_state_with_position(0, 0.0)
335}
336
337/// Creates a remembered [`LazyListState`] with the specified initial position.
338///
339/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
340#[composable]
341pub fn remember_lazy_list_state_with_position(
342    initial_first_visible_item_index: usize,
343    initial_first_visible_item_scroll_offset: f32,
344) -> LazyListState {
345    // Create scroll position with reactive fields (matches JC LazyListScrollPosition)
346    let scroll_position = LazyListScrollPosition {
347        index: cranpose_core::useState(|| initial_first_visible_item_index),
348        scroll_offset: cranpose_core::useState(|| initial_first_visible_item_scroll_offset),
349        inner: cranpose_core::useState(|| {
350            Rc::new(RefCell::new(ScrollPositionInner {
351                last_known_first_item_key: None,
352                nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
353            }))
354        }),
355    };
356
357    // Non-reactive internal state
358    let inner = cranpose_core::useState(|| {
359        Rc::new(RefCell::new(LazyListStateInner {
360            scroll_to_be_consumed: 0.0,
361            pending_scroll_to_index: None,
362            layout_info: LazyListLayoutInfo::default(),
363            invalidate_callbacks: Vec::new(),
364            next_callback_id: 1,
365            layout_invalidation_callback_id: None,
366            layout_invalidation_node_id: None,
367            total_composed: 0,
368            reuse_count: 0,
369            item_size_cache: std::collections::HashMap::new(),
370            item_size_lru: std::collections::VecDeque::new(),
371            average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
372            total_measured_items: 0,
373            prefetch_scheduler: PrefetchScheduler::new(),
374            prefetch_strategy: PrefetchStrategy::default(),
375            last_scroll_direction: 0.0,
376        }))
377    });
378
379    // Reactive state
380    let can_scroll_forward_state = cranpose_core::useState(|| false);
381    let can_scroll_backward_state = cranpose_core::useState(|| false);
382    let stats_state = cranpose_core::useState(LazyLayoutStats::default);
383
384    LazyListState {
385        scroll_position,
386        can_scroll_forward_state,
387        can_scroll_backward_state,
388        stats_state,
389        inner,
390    }
391}
392
393impl LazyListState {
394    /// Returns a stable identity pointer for the live inner state allocation.
395    ///
396    /// The pointer comes from the `Rc` stored inside `inner`, so it remains stable for the
397    /// lifetime of a live `LazyListState` and can be used as a composition identity key.
398    pub fn inner_ptr(&self) -> *const () {
399        self.inner
400            .try_with(|rc| Rc::as_ptr(rc) as *const ())
401            .unwrap_or(std::ptr::null())
402    }
403
404    /// Returns the index of the first visible item.
405    ///
406    /// When called during composition, this creates a reactive subscription
407    /// so that changes to the index will trigger recomposition.
408    pub fn first_visible_item_index(&self) -> usize {
409        // Delegate to scroll_position (reactive read)
410        self.scroll_position.index()
411    }
412
413    /// Returns the scroll offset of the first visible item.
414    ///
415    /// This is the amount the first item is scrolled off-screen (positive = scrolled up/left).
416    /// When called during composition, this creates a reactive subscription
417    /// so that changes to the offset will trigger recomposition.
418    pub fn first_visible_item_scroll_offset(&self) -> f32 {
419        // Delegate to scroll_position (reactive read)
420        self.scroll_position.scroll_offset()
421    }
422
423    /// Returns whether the list is positioned away from its origin without creating
424    /// a reactive subscription.
425    pub fn is_scrolled_non_reactive(&self) -> bool {
426        self.scroll_position.current_index() > 0
427            || self.scroll_position.current_scroll_offset().abs() > 0.001
428            || self
429                .inner
430                .try_with(|rc| {
431                    let inner = rc.borrow();
432                    inner.scroll_to_be_consumed.abs() > 0.001
433                        || inner
434                            .pending_scroll_to_index
435                            .is_some_and(|(index, offset)| index > 0 || offset.abs() > 0.001)
436                })
437                .unwrap_or(false)
438    }
439
440    /// Returns the layout info from the last measure pass.
441    pub fn layout_info(&self) -> LazyListLayoutInfo {
442        self.inner
443            .try_with(|rc| rc.borrow().layout_info.clone())
444            .unwrap_or_default()
445    }
446
447    /// Returns the current item lifecycle statistics.
448    ///
449    /// When called during composition, this creates a reactive subscription
450    /// so that changes to `items_in_use` or `items_in_pool` will trigger recomposition.
451    /// The `total_composed` and `reuse_count` fields are diagnostic and non-reactive.
452    pub fn stats(&self) -> LazyLayoutStats {
453        if !self.stats_state.is_alive() || !self.inner.is_alive() {
454            return LazyLayoutStats::default();
455        }
456        // Read reactive state (creates subscription) and combine with non-reactive counters
457        let reactive = self.stats_state.get();
458        let (total_composed, reuse_count) = self.inner.with(|rc| {
459            let inner = rc.borrow();
460            (inner.total_composed, inner.reuse_count)
461        });
462        LazyLayoutStats {
463            items_in_use: reactive.items_in_use,
464            items_in_pool: reactive.items_in_pool,
465            total_composed,
466            reuse_count,
467        }
468    }
469
470    /// Updates the item lifecycle statistics.
471    ///
472    /// Called by the layout measurement after updating slot pools.
473    /// Triggers recomposition if `items_in_use` or `items_in_pool` changed.
474    pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
475        if !self.stats_state.is_alive() || !self.inner.is_alive() {
476            return;
477        }
478
479        let current = self.stats_state.get_non_reactive();
480
481        // Hysteresis: only trigger reactive update when items_in_use INCREASES
482        // or DECREASES by more than 1. This prevents the 5→4→5→4 oscillation
483        // that happens at boundary conditions during slow upward scroll.
484        //
485        // Rationale:
486        // - Items becoming visible (increase): user should see count update immediately
487        // - Items going off-screen by 1: minor fluctuation, wait for significant change
488        // - Items going off-screen by 2+: significant change, update immediately
489        let should_update_reactive = if items_in_use > current.items_in_use {
490            // Increase: always update (new items visible)
491            true
492        } else if items_in_use < current.items_in_use {
493            // Decrease: only update if by more than 1 (prevents oscillation)
494            current.items_in_use - items_in_use > 1
495        } else {
496            false
497        };
498
499        if should_update_reactive {
500            self.stats_state.set(LazyLayoutStats {
501                items_in_use,
502                items_in_pool,
503                ..current
504            });
505        }
506        // Note: pool-only changes are intentionally not committed to reactive state
507        // to prevent the 5→4→5 oscillation that caused slow upward scroll hang.
508    }
509
510    /// Records that an item was composed (either new or reused).
511    ///
512    /// This updates diagnostic counters in non-reactive state.
513    /// Does NOT trigger recomposition.
514    pub fn record_composition(&self, was_reused: bool) {
515        if !self.inner.is_alive() {
516            return;
517        }
518        self.inner.with(|rc| {
519            let mut inner = rc.borrow_mut();
520            inner.total_composed += 1;
521            if was_reused {
522                inner.reuse_count += 1;
523            }
524        });
525    }
526
527    /// Records the raw scroll delta for prefetch calculations.
528    ///
529    /// Cranpose lazy lists use gesture-style deltas:
530    /// - Negative delta = scrolling forward (content moves up)
531    /// - Positive delta = scrolling backward (content moves down)
532    pub fn record_scroll_direction(&self, delta: f32) {
533        if delta.abs() > 0.001 {
534            if !self.inner.is_alive() {
535                return;
536            }
537            self.inner.with(|rc| {
538                rc.borrow_mut().last_scroll_direction = -delta.signum();
539            });
540        }
541    }
542
543    /// Updates the prefetch queue based on current visible items.
544    /// Should be called after measurement to queue items for pre-composition.
545    pub fn update_prefetch_queue(
546        &self,
547        first_visible_index: usize,
548        last_visible_index: usize,
549        total_items: usize,
550    ) {
551        if !self.inner.is_alive() {
552            return;
553        }
554        self.inner.with(|rc| {
555            let mut inner = rc.borrow_mut();
556            let direction = inner.last_scroll_direction;
557            let strategy = inner.prefetch_strategy.clone();
558            inner.prefetch_scheduler.update(
559                first_visible_index,
560                last_visible_index,
561                total_items,
562                direction,
563                &strategy,
564            );
565        });
566    }
567
568    /// Returns the indices that should be prefetched.
569    /// Consumes the prefetch queue.
570    pub fn take_prefetch_indices(&self) -> Vec<usize> {
571        self.inner
572            .try_with(|rc| {
573                let mut inner = rc.borrow_mut();
574                let mut indices = Vec::new();
575                while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
576                    indices.push(idx);
577                }
578                indices
579            })
580            .unwrap_or_default()
581    }
582
583    /// Scrolls to the specified item index.
584    ///
585    /// # Arguments
586    /// * `index` - The index of the item to scroll to
587    /// * `scroll_offset` - Additional offset within the item (default 0)
588    pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
589        if !self.inner.is_alive() {
590            return;
591        }
592        if lazy_measure_telemetry_enabled() {
593            log::warn!(
594                "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
595                index,
596                scroll_offset
597            );
598        }
599        // Store pending scroll request
600        self.inner.with(|rc| {
601            rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
602        });
603
604        // Delegate to scroll_position which handles reactive updates and key clearing
605        self.scroll_position
606            .request_position_and_forget_last_known_key(index, scroll_offset);
607
608        self.invalidate();
609    }
610
611    /// Dispatches a raw scroll delta.
612    ///
613    /// Returns the amount of scroll actually consumed.
614    ///
615    /// This triggers layout invalidation via registered callbacks. The callbacks are
616    /// registered by LazyColumnImpl/LazyRowImpl with schedule_layout_repass(node_id),
617    /// which provides O(subtree) performance instead of O(entire app).
618    pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
619        // Guard against stale handles: fling animation frame callbacks can fire
620        // after a tab switch disposes the composition group that owns this state.
621        if !self.inner.is_alive() {
622            return 0.0;
623        }
624        let has_scroll_bounds = self
625            .inner
626            .with(|rc| rc.borrow().layout_info.total_items_count > 0);
627        let pushing_forward = delta < -0.001;
628        let pushing_backward = delta > 0.001;
629        let can_scroll_forward = self.can_scroll_forward_state.is_alive()
630            && self.can_scroll_forward_state.get_non_reactive();
631        let can_scroll_backward = self.can_scroll_backward_state.is_alive()
632            && self.can_scroll_backward_state.get_non_reactive();
633        let blocked_by_bounds = has_scroll_bounds
634            && ((pushing_forward && !can_scroll_forward)
635                || (pushing_backward && !can_scroll_backward));
636
637        if blocked_by_bounds {
638            let should_invalidate = self.inner.with(|rc| {
639                let mut inner = rc.borrow_mut();
640                let pending_before = inner.scroll_to_be_consumed;
641                // If we're already at an edge, clear stale backlog in the same blocked direction.
642                if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
643                    inner.scroll_to_be_consumed = 0.0;
644                }
645                if lazy_measure_telemetry_enabled() {
646                    log::warn!(
647                        "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
648                        delta,
649                        pending_before,
650                        inner.scroll_to_be_consumed
651                    );
652                }
653                (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
654            });
655            if should_invalidate {
656                self.invalidate();
657            }
658            return 0.0;
659        }
660
661        let should_invalidate = self.inner.with(|rc| {
662            let mut inner = rc.borrow_mut();
663            let pending_before = inner.scroll_to_be_consumed;
664            let pending = inner.scroll_to_be_consumed;
665            let reverse_input = pending.abs() > 0.001
666                && delta.abs() > 0.001
667                && pending.signum() != delta.signum();
668            if reverse_input {
669                if lazy_measure_telemetry_enabled() {
670                    log::warn!(
671                        "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
672                        pending,
673                        delta
674                    );
675                }
676                // When gesture direction reverses, stale unconsumed backlog from the previous
677                // direction causes "snap back" behavior on slow frames. Keep only the latest
678                // direction intent.
679                inner.scroll_to_be_consumed = delta;
680            } else {
681                inner.scroll_to_be_consumed += delta;
682            }
683            inner.scroll_to_be_consumed = inner
684                .scroll_to_be_consumed
685                .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
686            if lazy_measure_telemetry_enabled() {
687                log::warn!(
688                    "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
689                    delta,
690                    inner.scroll_to_be_consumed
691                );
692            }
693            (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
694        });
695        if should_invalidate {
696            self.invalidate();
697        }
698        delta // Will be adjusted during layout
699    }
700
701    /// Peeks at the pending scroll delta without consuming it.
702    ///
703    /// Used for direction inference before measurement consumes the delta.
704    /// This is more accurate than comparing first visible index, especially for:
705    /// - Scrolling within the same item (partial scroll)
706    /// - Variable height items where scroll offset changes without index change
707    pub fn peek_scroll_delta(&self) -> f32 {
708        self.inner
709            .try_with(|rc| rc.borrow().scroll_to_be_consumed)
710            .unwrap_or(0.0)
711    }
712
713    pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
714        let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
715            .inner
716            .try_with(|rc| {
717                let mut inner = rc.borrow_mut();
718                let pending_scroll_delta = inner.scroll_to_be_consumed;
719                inner.scroll_to_be_consumed = 0.0;
720                let pending_scroll_to = inner.pending_scroll_to_index.take();
721                (
722                    pending_scroll_delta,
723                    pending_scroll_to,
724                    inner.average_item_size,
725                )
726            })
727            .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
728
729        LazyListMeasureStateSnapshot {
730            first_visible_item_index: self.scroll_position.current_index(),
731            first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
732            pending_scroll_delta,
733            pending_scroll_to,
734            average_item_size,
735        }
736    }
737
738    fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
739        inner.total_measured_items += 1;
740        let n = inner.total_measured_items as f32;
741        inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
742    }
743
744    fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
745        use std::collections::hash_map::Entry;
746
747        if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
748            entry.insert(size);
749            if let Some(pos) = inner
750                .item_size_lru
751                .iter()
752                .position(|&cached| cached == index)
753            {
754                inner.item_size_lru.remove(pos);
755            }
756            inner.item_size_lru.push_back(index);
757            return false;
758        }
759
760        while inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
761            if let Some(oldest) = inner.item_size_lru.pop_front() {
762                if inner.item_size_cache.remove(&oldest).is_some() {
763                    break;
764                }
765            } else {
766                break;
767            }
768        }
769
770        inner.item_size_cache.insert(index, size);
771        inner.item_size_lru.push_back(index);
772        true
773    }
774
775    /// Caches the measured size of an item for scroll estimation.
776    pub fn cache_item_size(&self, index: usize, size: f32) {
777        if !self.inner.is_alive() {
778            return;
779        }
780        self.inner.with(|rc| {
781            let mut inner = rc.borrow_mut();
782            if Self::insert_item_size(&mut inner, index, size) {
783                Self::record_item_size_sample(&mut inner, size);
784            }
785        });
786    }
787
788    /// Caches multiple measured item sizes in one pass and returns the updated average.
789    pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
790    where
791        I: IntoIterator<Item = (usize, f32)>,
792    {
793        if !self.inner.is_alive() {
794            return super::DEFAULT_ITEM_SIZE_ESTIMATE;
795        }
796
797        self.inner.with(|rc| {
798            let mut inner = rc.borrow_mut();
799            for (index, size) in sizes {
800                if Self::insert_item_size(&mut inner, index, size) {
801                    Self::record_item_size_sample(&mut inner, size);
802                }
803            }
804            inner.average_item_size
805        })
806    }
807
808    /// Gets a cached item size if available.
809    pub fn get_cached_size(&self, index: usize) -> Option<f32> {
810        self.inner
811            .try_with(|rc| rc.borrow().item_size_cache.get(&index).copied())
812            .flatten()
813    }
814
815    /// Returns the running average of measured item sizes.
816    pub fn average_item_size(&self) -> f32 {
817        self.inner
818            .try_with(|rc| rc.borrow().average_item_size)
819            .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
820    }
821
822    /// Returns the current nearest range for optimized key lookup.
823    pub fn nearest_range(&self) -> std::ops::Range<usize> {
824        // Delegate to scroll_position
825        self.scroll_position.nearest_range()
826    }
827
828    /// Updates the scroll position from a layout pass.
829    ///
830    /// Called by the layout after measurement.
831    pub(crate) fn update_scroll_position(
832        &self,
833        first_visible_item_index: usize,
834        first_visible_item_scroll_offset: f32,
835    ) {
836        self.scroll_position.update_from_measure_result(
837            first_visible_item_index,
838            first_visible_item_scroll_offset,
839            None,
840        );
841    }
842
843    /// Updates the scroll position and stores the key of the first visible item.
844    ///
845    /// Called by the layout after measurement to enable scroll position stability.
846    pub(crate) fn update_scroll_position_with_key(
847        &self,
848        first_visible_item_index: usize,
849        first_visible_item_scroll_offset: f32,
850        first_visible_item_key: u64,
851    ) {
852        self.scroll_position.update_from_measure_result(
853            first_visible_item_index,
854            first_visible_item_scroll_offset,
855            Some(first_visible_item_key),
856        );
857    }
858
859    /// Adjusts scroll position if the first visible item was moved due to data changes.
860    ///
861    /// Matches JC's `updateScrollPositionIfTheFirstItemWasMoved`.
862    /// If items were inserted/removed before the current scroll position,
863    /// this finds the item by its key and updates the index accordingly.
864    ///
865    /// Returns the adjusted first visible item index.
866    pub fn update_scroll_position_if_item_moved<F>(
867        &self,
868        new_item_count: usize,
869        get_index_by_key: F,
870    ) -> usize
871    where
872        F: Fn(u64) -> Option<usize>,
873    {
874        // Delegate to scroll_position
875        self.scroll_position
876            .update_if_first_item_moved(new_item_count, get_index_by_key)
877    }
878
879    /// Updates the layout info from a layout pass.
880    pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
881        if !self.inner.is_alive() {
882            return;
883        }
884        self.inner.with(|rc| {
885            let mut inner = rc.borrow_mut();
886            info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
887            inner.layout_info = info;
888        });
889    }
890
891    /// Returns whether we can scroll forward (more items below/right).
892    ///
893    /// When called during composition, this creates a reactive subscription
894    /// so that changes will trigger recomposition.
895    pub fn can_scroll_forward(&self) -> bool {
896        if !self.can_scroll_forward_state.is_alive() {
897            return false;
898        }
899        self.can_scroll_forward_state.get()
900    }
901
902    /// Returns whether we can scroll backward (more items above/left).
903    ///
904    /// When called during composition, this creates a reactive subscription
905    /// so that changes will trigger recomposition.
906    pub fn can_scroll_backward(&self) -> bool {
907        if !self.can_scroll_backward_state.is_alive() {
908            return false;
909        }
910        self.can_scroll_backward_state.get()
911    }
912
913    /// Updates the scroll bounds after layout measurement.
914    ///
915    /// Called by the layout after measurement to update can_scroll_forward/backward.
916    pub(crate) fn update_scroll_bounds(&self) {
917        if !self.inner.is_alive()
918            || !self.can_scroll_forward_state.is_alive()
919            || !self.can_scroll_backward_state.is_alive()
920        {
921            return;
922        }
923        // Compute can_scroll_forward from layout info
924        let can_forward = self.inner.with(|rc| {
925            let inner = rc.borrow();
926            let info = &inner.layout_info;
927            // Use effective viewport end (accounting for after_content_padding)
928            // Without this, lists with padding can report false while still scrollable
929            let viewport_end = info.viewport_size - info.after_content_padding;
930            if let Some(last_visible) = info.visible_items_info.last() {
931                last_visible.index < info.total_items_count.saturating_sub(1)
932                    || (last_visible.offset + last_visible.size) > viewport_end
933            } else {
934                false
935            }
936        });
937
938        // Compute can_scroll_backward from scroll position
939        let can_backward = self.scroll_position.current_index() > 0
940            || self.scroll_position.current_scroll_offset() > 0.0;
941
942        // Update reactive state only if changed
943        if self.can_scroll_forward_state.get_non_reactive() != can_forward {
944            self.can_scroll_forward_state.set(can_forward);
945        }
946        if self.can_scroll_backward_state.get_non_reactive() != can_backward {
947            self.can_scroll_backward_state.set(can_backward);
948        }
949    }
950
951    /// Adds an invalidation callback.
952    pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
953        if !self.inner.is_alive() {
954            return 0;
955        }
956        self.inner.with(|rc| {
957            let mut inner = rc.borrow_mut();
958            let id = inner.next_callback_id;
959            inner.next_callback_id += 1;
960            inner.invalidate_callbacks.push((id, callback));
961            id
962        })
963    }
964
965    /// Tries to register a layout invalidation callback for the specified node.
966    ///
967    /// Returns the callback id for the active layout callback.
968    ///
969    /// Registering again always replaces the previous active layout callback, even when
970    /// the node id stays the same. This keeps ownership tied to the latest effect
971    /// instance so disposing an older scope cannot unregister the live callback.
972    pub fn try_register_layout_callback(
973        &self,
974        node_id: NodeId,
975        callback: Rc<dyn Fn()>,
976    ) -> Option<u64> {
977        if !self.inner.is_alive() {
978            return None;
979        }
980        self.inner.with(|rc| {
981            let mut inner = rc.borrow_mut();
982            if let Some(existing_id) = inner.layout_invalidation_callback_id {
983                inner
984                    .invalidate_callbacks
985                    .retain(|(cb_id, _)| *cb_id != existing_id);
986            }
987            let id = inner.next_callback_id;
988            inner.next_callback_id += 1;
989            inner.invalidate_callbacks.push((id, callback));
990            inner.layout_invalidation_callback_id = Some(id);
991            inner.layout_invalidation_node_id = Some(node_id);
992            Some(id)
993        })
994    }
995
996    /// Removes an invalidation callback.
997    pub fn remove_invalidate_callback(&self, id: u64) {
998        if !self.inner.is_alive() {
999            return;
1000        }
1001        self.inner.with(|rc| {
1002            let mut inner = rc.borrow_mut();
1003            inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1004            if inner.layout_invalidation_callback_id == Some(id) {
1005                inner.layout_invalidation_callback_id = None;
1006                inner.layout_invalidation_node_id = None;
1007            }
1008        });
1009    }
1010
1011    fn invalidate(&self) {
1012        if !self.inner.is_alive() {
1013            return;
1014        }
1015        // Clone callbacks to avoid holding the borrow while calling them
1016        // This prevents re-entrancy issues if a callback triggers another state update
1017        let callbacks: Vec<_> = self.inner.with(|rc| {
1018            rc.borrow()
1019                .invalidate_callbacks
1020                .iter()
1021                .map(|(_, cb)| Rc::clone(cb))
1022                .collect()
1023        });
1024
1025        for callback in callbacks {
1026            callback();
1027        }
1028    }
1029}
1030
1031/// Information about the currently visible items in a lazy list.
1032#[derive(Clone, Default, Debug)]
1033pub struct LazyListLayoutInfo {
1034    /// Information about each visible item.
1035    pub visible_items_info: Vec<LazyListItemInfo>,
1036
1037    /// Total number of items in the list.
1038    pub total_items_count: usize,
1039
1040    /// Raw viewport size reported by parent constraints (before infinite fallback).
1041    pub raw_viewport_size: f32,
1042
1043    /// Whether the viewport was treated as infinite/unbounded.
1044    pub is_infinite_viewport: bool,
1045
1046    /// Size of the viewport in the main axis.
1047    pub viewport_size: f32,
1048
1049    /// Start offset of the viewport (content padding before).
1050    pub viewport_start_offset: f32,
1051
1052    /// End offset of the viewport (content padding after).
1053    pub viewport_end_offset: f32,
1054
1055    /// Content padding before the first item.
1056    pub before_content_padding: f32,
1057
1058    /// Content padding after the last item.
1059    pub after_content_padding: f32,
1060
1061    /// Continuous main-axis visual offset used to snap translated lazy-list content.
1062    pub snap_anchor_offset: f32,
1063
1064    /// Whether item offsets are placed from the end edge of the viewport.
1065    pub reverse_layout: bool,
1066}
1067
1068/// Information about a single visible item in a lazy list.
1069#[derive(Clone, Debug)]
1070pub struct LazyListItemInfo {
1071    /// Index of the item in the data source.
1072    pub index: usize,
1073
1074    /// Key of the item.
1075    pub key: u64,
1076
1077    /// Offset of the item from the start of the list content.
1078    pub offset: f32,
1079
1080    /// Size of the item in the main axis.
1081    pub size: f32,
1082}
1083
1084fn continuous_snap_anchor_offset(
1085    previous: &LazyListLayoutInfo,
1086    current: &LazyListLayoutInfo,
1087) -> f32 {
1088    let Some(first_current) = current.visible_items_info.first() else {
1089        return 0.0;
1090    };
1091
1092    for current_item in &current.visible_items_info {
1093        if let Some(previous_item) = previous
1094            .visible_items_info
1095            .iter()
1096            .find(|item| item.key == current_item.key)
1097        {
1098            let previous_offset = snap_anchor_item_offset(previous, previous_item);
1099            let current_offset = snap_anchor_item_offset(current, current_item);
1100            return previous.snap_anchor_offset + current_offset - previous_offset;
1101        }
1102    }
1103
1104    snap_anchor_item_offset(current, first_current)
1105}
1106
1107fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1108    if info.reverse_layout {
1109        info.viewport_size - item.offset - item.size
1110    } else {
1111        item.offset
1112    }
1113}
1114
1115/// Test helpers for creating LazyListState without composition context.
1116#[cfg(test)]
1117pub mod test_helpers {
1118    use super::*;
1119    use cranpose_core::{DefaultScheduler, Runtime};
1120    use std::sync::Arc;
1121
1122    /// Creates a test runtime and keeps it alive for the duration of the closure.
1123    /// Use this to create LazyListState in unit tests.
1124    pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1125        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1126        f()
1127    }
1128
1129    /// Creates a new LazyListState for testing.
1130    /// Must be called within `with_test_runtime`.
1131    pub fn new_lazy_list_state() -> LazyListState {
1132        new_lazy_list_state_with_position(0, 0.0)
1133    }
1134
1135    /// Creates a new LazyListState for testing with initial position.
1136    /// Must be called within `with_test_runtime`.
1137    pub fn new_lazy_list_state_with_position(
1138        initial_first_visible_item_index: usize,
1139        initial_first_visible_item_scroll_offset: f32,
1140    ) -> LazyListState {
1141        // Create scroll position with reactive fields (matches JC LazyListScrollPosition)
1142        let scroll_position = LazyListScrollPosition {
1143            index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1144            scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1145            inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1146                last_known_first_item_key: None,
1147                nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1148            }))),
1149        };
1150
1151        // Non-reactive internal state
1152        let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1153            scroll_to_be_consumed: 0.0,
1154            pending_scroll_to_index: None,
1155            layout_info: LazyListLayoutInfo::default(),
1156            invalidate_callbacks: Vec::new(),
1157            next_callback_id: 1,
1158            layout_invalidation_callback_id: None,
1159            layout_invalidation_node_id: None,
1160            total_composed: 0,
1161            reuse_count: 0,
1162            item_size_cache: std::collections::HashMap::new(),
1163            item_size_lru: std::collections::VecDeque::new(),
1164            average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1165            total_measured_items: 0,
1166            prefetch_scheduler: PrefetchScheduler::new(),
1167            prefetch_strategy: PrefetchStrategy::default(),
1168            last_scroll_direction: 0.0,
1169        })));
1170
1171        // Reactive state
1172        let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1173        let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1174        let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1175
1176        LazyListState {
1177            scroll_position,
1178            can_scroll_forward_state,
1179            can_scroll_backward_state,
1180            stats_state,
1181            inner,
1182        }
1183    }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::test_helpers::{
1189        new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime,
1190    };
1191    use super::{LazyListItemInfo, LazyListLayoutInfo, LazyListState};
1192    use cranpose_core::{location_key, Composition, MemoryApplier};
1193    use std::cell::Cell;
1194    use std::rc::Rc;
1195
1196    fn enable_bidirectional_scroll(state: &LazyListState) {
1197        state.can_scroll_forward_state.set(true);
1198        state.can_scroll_backward_state.set(true);
1199    }
1200
1201    fn mark_scroll_bounds_known(state: &LazyListState) {
1202        state.update_layout_info(LazyListLayoutInfo {
1203            total_items_count: 10,
1204            ..Default::default()
1205        });
1206    }
1207
1208    fn visible_item(index: usize, offset: f32, size: f32) -> LazyListItemInfo {
1209        LazyListItemInfo {
1210            index,
1211            key: index as u64,
1212            offset,
1213            size,
1214        }
1215    }
1216
1217    #[test]
1218    fn layout_info_snap_anchor_tracks_common_item_offset_delta() {
1219        let previous = LazyListLayoutInfo {
1220            visible_items_info: vec![visible_item(15, -31.4, 30.0), visible_item(16, 4.6, 30.0)],
1221            snap_anchor_offset: -31.4,
1222            ..Default::default()
1223        };
1224        let current = LazyListLayoutInfo {
1225            visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1226            ..Default::default()
1227        };
1228
1229        let anchor = super::continuous_snap_anchor_offset(&previous, &current);
1230
1231        assert!((anchor + 32.4).abs() <= 0.001);
1232    }
1233
1234    #[test]
1235    fn layout_info_snap_anchor_uses_reverse_visual_item_offset() {
1236        let previous = LazyListLayoutInfo {
1237            visible_items_info: vec![visible_item(15, 31.4, 30.0), visible_item(16, 67.4, 30.0)],
1238            snap_anchor_offset: 58.6,
1239            viewport_size: 120.0,
1240            reverse_layout: true,
1241            ..Default::default()
1242        };
1243        let current = LazyListLayoutInfo {
1244            visible_items_info: vec![visible_item(16, 68.4, 30.0), visible_item(17, 104.4, 30.0)],
1245            viewport_size: 120.0,
1246            reverse_layout: true,
1247            ..Default::default()
1248        };
1249
1250        let anchor = super::continuous_snap_anchor_offset(&previous, &current);
1251
1252        assert!((anchor - 57.6).abs() <= 0.001);
1253    }
1254
1255    #[test]
1256    fn update_layout_info_keeps_snap_anchor_continuous_when_first_visible_item_changes() {
1257        with_test_runtime(|| {
1258            let state = new_lazy_list_state();
1259            state.update_layout_info(LazyListLayoutInfo {
1260                visible_items_info: vec![
1261                    visible_item(15, -31.4, 30.0),
1262                    visible_item(16, 4.6, 30.0),
1263                ],
1264                ..Default::default()
1265            });
1266
1267            state.update_layout_info(LazyListLayoutInfo {
1268                visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1269                ..Default::default()
1270            });
1271
1272            let info = state.layout_info();
1273            assert!((info.snap_anchor_offset + 32.4).abs() <= 0.001);
1274        });
1275    }
1276
1277    #[test]
1278    fn dispatch_scroll_delta_accumulates_same_direction() {
1279        with_test_runtime(|| {
1280            let state = new_lazy_list_state();
1281            enable_bidirectional_scroll(&state);
1282
1283            state.dispatch_scroll_delta(-12.0);
1284            state.dispatch_scroll_delta(-8.0);
1285
1286            assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1287            let snapshot = state.begin_measure_pass();
1288            assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1289            assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1290        });
1291    }
1292
1293    #[test]
1294    fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1295        with_test_runtime(|| {
1296            let state = new_lazy_list_state();
1297            enable_bidirectional_scroll(&state);
1298
1299            state.dispatch_scroll_delta(-120.0);
1300            state.dispatch_scroll_delta(-30.0);
1301            assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1302
1303            state.dispatch_scroll_delta(18.0);
1304
1305            assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1306            let snapshot = state.begin_measure_pass();
1307            assert!((snapshot.pending_scroll_delta - 18.0).abs() < 0.001);
1308            assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1309        });
1310    }
1311
1312    #[test]
1313    fn dispatch_scroll_delta_clamps_pending_backlog() {
1314        with_test_runtime(|| {
1315            let state = new_lazy_list_state();
1316            enable_bidirectional_scroll(&state);
1317
1318            state.dispatch_scroll_delta(-1_500.0);
1319            state.dispatch_scroll_delta(-1_500.0);
1320            assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1321
1322            state.dispatch_scroll_delta(3_000.0);
1323            assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1324        });
1325    }
1326
1327    #[test]
1328    fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1329        with_test_runtime(|| {
1330            let state = new_lazy_list_state();
1331            enable_bidirectional_scroll(&state);
1332            let invalidations = Rc::new(Cell::new(0u32));
1333            let invalidations_clone = Rc::clone(&invalidations);
1334            state.add_invalidate_callback(Rc::new(move || {
1335                invalidations_clone.set(invalidations_clone.get() + 1);
1336            }));
1337
1338            state.dispatch_scroll_delta(-3_000.0);
1339            assert_eq!(invalidations.get(), 1);
1340            assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1341
1342            // Additional same-direction input is clamped to the same pending value.
1343            state.dispatch_scroll_delta(-100.0);
1344            assert_eq!(invalidations.get(), 1);
1345
1346            // Opposite-direction input changes pending and should invalidate again.
1347            state.dispatch_scroll_delta(100.0);
1348            assert_eq!(invalidations.get(), 2);
1349        });
1350    }
1351
1352    #[test]
1353    fn begin_measure_pass_takes_coherent_snapshot_and_consumes_pending_inputs() {
1354        with_test_runtime(|| {
1355            let state = new_lazy_list_state_with_position(3, 12.0);
1356            state.dispatch_scroll_delta(-20.0);
1357            state.inner.with(|rc| {
1358                rc.borrow_mut().pending_scroll_to_index = Some((8, 4.0));
1359            });
1360
1361            let snapshot = state.begin_measure_pass();
1362
1363            assert_eq!(snapshot.first_visible_item_index, 3);
1364            assert!((snapshot.first_visible_item_scroll_offset - 12.0).abs() < 0.001);
1365            assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1366            assert_eq!(snapshot.pending_scroll_to, Some((8, 4.0)));
1367            assert_eq!(state.peek_scroll_delta(), 0.0);
1368            assert_eq!(state.begin_measure_pass().pending_scroll_to, None);
1369        });
1370    }
1371
1372    #[test]
1373    fn item_size_cache_refresh_keeps_recent_entry_and_evicts_oldest_live_entry() {
1374        with_test_runtime(|| {
1375            let state = new_lazy_list_state();
1376            for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1377                state.cache_item_size(index, index as f32 + 10.0);
1378            }
1379
1380            state.cache_item_size(0, 999.0);
1381            state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1382
1383            assert_eq!(state.get_cached_size(0), Some(999.0));
1384            assert_eq!(state.get_cached_size(1), None);
1385            assert_eq!(
1386                state.get_cached_size(super::ITEM_SIZE_CACHE_CAPACITY),
1387                Some(123.0),
1388            );
1389        });
1390    }
1391
1392    #[test]
1393    fn cache_item_sizes_updates_average_only_for_new_entries() {
1394        with_test_runtime(|| {
1395            let state = new_lazy_list_state();
1396
1397            let average = state.cache_item_sizes([(0, 10.0), (1, 20.0), (0, 12.0)]);
1398
1399            assert_eq!(state.get_cached_size(0), Some(12.0));
1400            assert_eq!(state.get_cached_size(1), Some(20.0));
1401            assert!((average - 15.0).abs() < 0.001);
1402        });
1403    }
1404
1405    #[test]
1406    fn layout_callback_can_be_registered_again_after_removal() {
1407        with_test_runtime(|| {
1408            let state = new_lazy_list_state();
1409            let first_node: cranpose_core::NodeId = 1;
1410            let second_node: cranpose_core::NodeId = 2;
1411
1412            let first_id = state
1413                .try_register_layout_callback(first_node, Rc::new(|| {}))
1414                .expect("first layout callback should register");
1415            let duplicate_id = state
1416                .try_register_layout_callback(first_node, Rc::new(|| {}))
1417                .expect("duplicate register should replace with a fresh callback id");
1418            assert_eq!(
1419                state
1420                    .inner
1421                    .with(|rc| rc.borrow().layout_invalidation_callback_id),
1422                Some(duplicate_id),
1423                "duplicate registration should become the active callback",
1424            );
1425            assert_ne!(
1426                first_id, duplicate_id,
1427                "duplicate registration should replace the old callback id",
1428            );
1429
1430            state.remove_invalidate_callback(first_id);
1431
1432            let second_id = state
1433                .try_register_layout_callback(second_node, Rc::new(|| {}))
1434                .expect("layout callback should register again after removal");
1435            assert_ne!(first_id, second_id);
1436        });
1437    }
1438
1439    #[test]
1440    fn layout_callback_rebinds_when_node_id_changes() {
1441        with_test_runtime(|| {
1442            let state = new_lazy_list_state();
1443            let first_node: cranpose_core::NodeId = 11;
1444            let second_node: cranpose_core::NodeId = 22;
1445
1446            let first_id = state
1447                .try_register_layout_callback(first_node, Rc::new(|| {}))
1448                .expect("first layout callback should register");
1449
1450            let second_id = state
1451                .try_register_layout_callback(second_node, Rc::new(|| {}))
1452                .expect("layout callback should rebind to a new node");
1453
1454            assert_ne!(first_id, second_id);
1455        });
1456    }
1457
1458    #[test]
1459    fn stale_layout_callback_disposer_cannot_remove_replaced_same_node_callback() {
1460        with_test_runtime(|| {
1461            let state = new_lazy_list_state();
1462            let node_id: cranpose_core::NodeId = 7;
1463            let first_hits = Rc::new(Cell::new(0u32));
1464            let second_hits = Rc::new(Cell::new(0u32));
1465
1466            let first_id = state
1467                .try_register_layout_callback(
1468                    node_id,
1469                    Rc::new({
1470                        let first_hits = Rc::clone(&first_hits);
1471                        move || first_hits.set(first_hits.get() + 1)
1472                    }),
1473                )
1474                .expect("first layout callback should register");
1475
1476            let second_id = state
1477                .try_register_layout_callback(
1478                    node_id,
1479                    Rc::new({
1480                        let second_hits = Rc::clone(&second_hits);
1481                        move || second_hits.set(second_hits.get() + 1)
1482                    }),
1483                )
1484                .expect("same-node registration should replace the active callback");
1485
1486            assert_ne!(first_id, second_id);
1487
1488            state.remove_invalidate_callback(first_id);
1489            state.dispatch_scroll_delta(-12.0);
1490
1491            assert_eq!(
1492                first_hits.get(),
1493                0,
1494                "replaced callback should not be invoked after removal",
1495            );
1496            assert_eq!(
1497                second_hits.get(),
1498                1,
1499                "active callback should survive stale disposer cleanup",
1500            );
1501        });
1502    }
1503
1504    #[test]
1505    fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1506        with_test_runtime(|| {
1507            let state = new_lazy_list_state();
1508            mark_scroll_bounds_known(&state);
1509            state.can_scroll_forward_state.set(false);
1510            state.can_scroll_backward_state.set(true);
1511
1512            let consumed = state.dispatch_scroll_delta(-24.0);
1513
1514            assert_eq!(consumed, 0.0);
1515            assert_eq!(state.peek_scroll_delta(), 0.0);
1516        });
1517    }
1518
1519    #[test]
1520    fn equality_does_not_deref_released_inner_state() {
1521        let mut composition = Composition::new(MemoryApplier::new());
1522        let key = location_key(file!(), line!(), column!());
1523
1524        let mut first = None;
1525        composition
1526            .render(key, || {
1527                first = Some(super::remember_lazy_list_state());
1528            })
1529            .expect("initial render");
1530        let first = first.expect("first lazy state");
1531
1532        composition
1533            .render(key, || {})
1534            .expect("dispose first lazy state");
1535        assert!(
1536            !first.inner.is_alive(),
1537            "expected first lazy state to be released after disposal"
1538        );
1539
1540        let mut second = None;
1541        composition
1542            .render(key, || {
1543                second = Some(super::remember_lazy_list_state());
1544            })
1545            .expect("second render");
1546        let second = second.expect("second lazy state");
1547
1548        assert!(
1549            first != second,
1550            "released lazy state handle must compare by identity without panicking"
1551        );
1552    }
1553
1554    #[test]
1555    fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1556        let mut composition = Composition::new(MemoryApplier::new());
1557        let key = location_key(file!(), line!(), column!());
1558
1559        let mut released = None;
1560        composition
1561            .render(key, || {
1562                released = Some(super::remember_lazy_list_state());
1563            })
1564            .expect("initial render");
1565        let released = released.expect("lazy list state");
1566
1567        composition
1568            .render(key, || {})
1569            .expect("dispose lazy list state");
1570        assert!(
1571            !released.inner.is_alive(),
1572            "expected lazy list state to be released after disposal"
1573        );
1574
1575        assert_eq!(released.first_visible_item_index(), 0);
1576        assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1577        assert_eq!(released.nearest_range(), 0..0);
1578        assert_eq!(
1579            released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1580            0
1581        );
1582        released.update_scroll_position(3, 12.0);
1583        released.update_scroll_position_with_key(3, 12.0, 42);
1584        released.update_scroll_bounds();
1585    }
1586
1587    #[test]
1588    fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1589        with_test_runtime(|| {
1590            let state = new_lazy_list_state();
1591            mark_scroll_bounds_known(&state);
1592            enable_bidirectional_scroll(&state);
1593            state.dispatch_scroll_delta(-300.0);
1594            assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1595
1596            state.can_scroll_forward_state.set(false);
1597
1598            let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1599            assert_eq!(blocked_consumed, 0.0);
1600            assert_eq!(state.peek_scroll_delta(), 0.0);
1601
1602            let reverse_consumed = state.dispatch_scroll_delta(12.0);
1603            assert_eq!(reverse_consumed, 12.0);
1604            assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1605        });
1606    }
1607
1608    #[test]
1609    fn negative_scroll_delta_prefetches_forward_items() {
1610        with_test_runtime(|| {
1611            let state = new_lazy_list_state();
1612            state.dispatch_scroll_delta(-24.0);
1613            state.record_scroll_direction(state.peek_scroll_delta());
1614            state.update_prefetch_queue(10, 15, 100);
1615
1616            assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1617        });
1618    }
1619}