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