| Crates.io | ankurah-virtual-scroll |
| lib.rs | ankurah-virtual-scroll |
| version | 0.7.7 |
| created_at | 2026-01-13 04:21:29.811937+00 |
| updated_at | 2026-01-23 00:49:01.782298+00 |
| description | Platform-agnostic virtual scroll state machine with pagination for Ankurah |
| homepage | |
| repository | https://github.com/ankurah/virtual-scroll |
| max_upload_size | |
| id | 2039306 |
| size | 183,031 |
Platform-agnostic virtual scroll state machine with pagination for Ankurah.
ankurah-virtual-scroll provides smooth infinite scrolling through database-backed lists without loading everything into memory. It maintains a sliding window of items, expanding or sliding the window as the user scrolls, while preserving scroll position stability through intersection anchoring.
[dependencies]
ankurah-virtual-scroll = "0.7"
Use ScrollManager<V> directly - no macro needed:
/// Example: Creating and using a ScrollManager
#[allow(dead_code)]
async fn scroll_manager_example() -> Result<(), Box<dyn std::error::Error>> {
let ctx = durable_sled_setup().await?;
// Create scroll manager with full configuration
let scroll_manager = ScrollManager::<TestMessageView>::new(
&ctx,
"true", // Filter predicate (e.g., "room = 'general'")
"timestamp DESC", // Display order
40, // Minimum row height (pixels)
2.0, // Buffer factor (2.0 = 2x viewport)
600, // Viewport height (pixels)
)?;
// Initialize (runs initial query)
scroll_manager.start().await;
// Read visible items from the signal
let visible_set = scroll_manager.visible_set().get();
for item in &visible_set.items {
// Access item fields via the View trait
let _id = item.entity().id();
}
// Notify on scroll events with first/last visible EntityIds
if let (Some(first), Some(last)) = (visible_set.items.first(), visible_set.items.last()) {
let first_visible_id = first.entity().id();
let last_visible_id = last.entity().id();
let scrolling_backward = true; // true = scrolling toward older items
scroll_manager.on_scroll(first_visible_id, last_visible_id, scrolling_backward);
}
Ok(())
}
For JavaScript/TypeScript frontends, use the generate_scroll_manager! macro in your bindings crate to generate platform-specific wrappers:
// Generate MessageScrollManager with WASM bindings
ankurah_virtual_scroll::generate_scroll_manager!(
Message,
MessageView,
MessageLiveQuery,
timestamp_field = "timestamp"
);
This generates MessageScrollManager with the appropriate bindings based on feature flags:
wasm feature: generates #[wasm_bindgen] bindings for React web appsuniffi feature: generates UniFFI bindings for React Native apps (in development)import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
// Types from WASM bindings (generated by ankurah-virtual-scroll-derive)
interface MessageView {
id: () => { toString: () => string }
text: () => string
}
interface MessageVisibleSet {
items: MessageView[]
}
interface MessageVisibleSetSignal {
get: () => MessageVisibleSet
}
interface MessageScrollManager {
start: () => Promise<void>
visibleSet: () => MessageVisibleSetSignal
onScroll: (firstVisible: string, lastVisible: string, scrollingBackward: boolean) => void
}
// These would come from your WASM bindings package
declare function ctx(): unknown
declare const MessageScrollManager: new (
ctx: unknown,
predicate: string,
orderBy: string,
minRowHeight: number,
bufferFactor: number,
viewportHeight: number
) => MessageScrollManager
const VIEWPORT_HEIGHT = 400
const MIN_ROW_HEIGHT = 40
export function ExampleMessageList({ roomId }: { roomId: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const lastScrollTop = useRef(0)
const [items, setItems] = useState<MessageView[]>([])
// Create scroll manager once per room
const manager = useMemo(() => {
return new MessageScrollManager(
ctx(),
`room = '${roomId}'`,
'timestamp DESC',
MIN_ROW_HEIGHT,
2.0,
VIEWPORT_HEIGHT
)
}, [roomId])
// Initialize and sync state on mount
useEffect(() => {
manager.start().then(() => {
const vs = manager.visibleSet().get()
setItems([...vs.items])
})
}, [manager])
// Find first/last visible items by checking DOM element positions
const findVisibleItems = useCallback(() => {
const container = containerRef.current
if (!container) return null
const elements = container.querySelectorAll('[data-item-id]')
let firstId: string | null = null
let lastId: string | null = null
elements.forEach(el => {
const rect = el.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
// Item is visible if it overlaps with container viewport
if (rect.bottom > containerRect.top && rect.top < containerRect.bottom) {
const id = el.getAttribute('data-item-id')
if (id) {
if (!firstId) firstId = id
lastId = id
}
}
})
return firstId && lastId ? { firstId, lastId } : null
}, [])
// Handle scroll events - detect direction and notify scroll manager
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
const scrollingBackward = el.scrollTop < lastScrollTop.current
lastScrollTop.current = el.scrollTop
const visible = findVisibleItems()
if (visible) {
// Pass EntityId strings (not pixel values) to scroll manager
manager.onScroll(visible.firstId, visible.lastId, scrollingBackward)
// Sync state after potential window slide
const vs = manager.visibleSet().get()
setItems([...vs.items])
}
}, [manager, findVisibleItems])
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{ height: VIEWPORT_HEIGHT, overflowY: 'auto' }}
>
{items.map(msg => (
<div key={msg.id().toString()} data-item-id={msg.id().toString()}>
{msg.text()}
</div>
))}
</div>
)
}
The scroll manager handles:
Platform layers handle:
ankurah-virtual-scroll - Core scroll manager implementationankurah-virtual-scroll-derive - Derive macro for generating typed scroll managersMinor versions align with ankurah (e.g., 0.7.x works with ankurah 0.7.x). Patch versions are independent.
MIT OR Apache-2.0