1use 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#[derive(Clone, Debug, Default, PartialEq)]
35pub struct LazyLayoutStats {
36 pub items_in_use: usize,
38
39 pub items_in_pool: usize,
41
42 pub total_composed: usize,
44
45 pub reuse_count: usize,
47}
48
49#[derive(Clone, Copy)]
61pub struct LazyListScrollPosition {
62 index: MutableState<usize>,
64 scroll_offset: MutableState<f32>,
66 inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
68}
69
70struct ScrollPositionInner {
72 last_known_first_item_key: Option<u64>,
75 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 pub fn index(&self) -> usize {
94 if !self.index.is_alive() {
95 return 0;
96 }
97 self.index.get()
98 }
99
100 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 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 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 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 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 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 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 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 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#[derive(Clone, Copy)]
242pub struct LazyListState {
243 scroll_position: LazyListScrollPosition,
245 can_scroll_forward_state: MutableState<bool>,
247 can_scroll_backward_state: MutableState<bool>,
249 stats_state: MutableState<LazyLayoutStats>,
252 inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
254}
255
256impl PartialEq for LazyListState {
260 fn eq(&self, other: &Self) -> bool {
261 self.inner == other.inner
262 }
263}
264
265struct LazyListStateInner {
267 scroll_to_be_consumed: f32,
269
270 pending_scroll_to_index: Option<(usize, f32)>,
272
273 layout_info: LazyListLayoutInfo,
275
276 invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
278 next_callback_id: u64,
279
280 layout_invalidation_callback_id: Option<u64>,
284 layout_invalidation_node_id: Option<NodeId>,
285
286 total_composed: usize,
288 reuse_count: usize,
289
290 item_size_cache: std::collections::HashMap<usize, f32>,
292 item_size_lru: std::collections::VecDeque<usize>,
294
295 average_item_size: f32,
297 total_measured_items: usize,
298
299 prefetch_scheduler: PrefetchScheduler,
301
302 prefetch_strategy: PrefetchStrategy,
304
305 last_scroll_direction: f32,
307}
308
309#[composable]
324pub fn remember_lazy_list_state() -> LazyListState {
325 remember_lazy_list_state_with_position(0, 0.0)
326}
327
328#[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 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 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 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 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 pub fn first_visible_item_index(&self) -> usize {
400 self.scroll_position.index()
402 }
403
404 pub fn first_visible_item_scroll_offset(&self) -> f32 {
410 self.scroll_position.scroll_offset()
412 }
413
414 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 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 pub fn stats(&self) -> LazyLayoutStats {
444 if !self.stats_state.is_alive() || !self.inner.is_alive() {
445 return LazyLayoutStats::default();
446 }
447 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 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 let should_update_reactive = if items_in_use > current.items_in_use {
481 true
483 } else if items_in_use < current.items_in_use {
484 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 }
500
501 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 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 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 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 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 self.inner.with(|rc| {
592 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
593 });
594
595 self.scroll_position
597 .request_position_and_forget_last_known_key(index, scroll_offset);
598
599 self.invalidate();
600 }
601
602 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
610 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 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 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 }
687
688 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 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 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 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 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
743 entry.insert(size);
745 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 while inner.item_size_cache.len() >= MAX_CACHE_SIZE {
755 if let Some(oldest) = inner.item_size_lru.pop_front() {
756 if inner.item_size_cache.remove(&oldest).is_some() {
758 break; }
760 } else {
761 break; }
763 }
764
765 inner.item_size_cache.insert(index, size);
767 inner.item_size_lru.push_back(index);
768
769 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 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 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 pub fn nearest_range(&self) -> std::ops::Range<usize> {
792 self.scroll_position.nearest_range()
794 }
795
796 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 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 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 self.scroll_position
844 .update_if_first_item_moved(new_item_count, get_index_by_key)
845 }
846
847 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 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 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 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 let can_forward = self.inner.with(|rc| {
889 let inner = rc.borrow();
890 let info = &inner.layout_info;
891 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 let can_backward = self.scroll_position.current_index() > 0
904 || self.scroll_position.current_scroll_offset() > 0.0;
905
906 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 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 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 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 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#[derive(Clone, Default, Debug)]
997pub struct LazyListLayoutInfo {
998 pub visible_items_info: Vec<LazyListItemInfo>,
1000
1001 pub total_items_count: usize,
1003
1004 pub raw_viewport_size: f32,
1006
1007 pub is_infinite_viewport: bool,
1009
1010 pub viewport_size: f32,
1012
1013 pub viewport_start_offset: f32,
1015
1016 pub viewport_end_offset: f32,
1018
1019 pub before_content_padding: f32,
1021
1022 pub after_content_padding: f32,
1024}
1025
1026#[derive(Clone, Debug)]
1028pub struct LazyListItemInfo {
1029 pub index: usize,
1031
1032 pub key: u64,
1034
1035 pub offset: f32,
1037
1038 pub size: f32,
1040}
1041
1042#[cfg(test)]
1044pub mod test_helpers {
1045 use super::*;
1046 use cranpose_core::{DefaultScheduler, Runtime};
1047 use std::sync::Arc;
1048
1049 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1052 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1053 f()
1054 }
1055
1056 pub fn new_lazy_list_state() -> LazyListState {
1059 new_lazy_list_state_with_position(0, 0.0)
1060 }
1061
1062 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 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 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 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 state.dispatch_scroll_delta(-100.0);
1198 assert_eq!(invalidations.get(), 1);
1199
1200 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}