# ankurah-virtual-scroll
Platform-agnostic virtual scroll state machine with pagination for Ankurah.
## Overview
`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.
## Features
- **Bidirectional pagination**: Load older and newer content seamlessly
- **Scroll position stability**: Maintain scroll position when loading new items via intersection anchoring
- **Reactive integration**: Works with Ankurah's LiveQuery for real-time updates
- **Platform-agnostic**: Core logic in Rust with WASM bindings (UniFFI in development)
- **Variable item heights**: Handles items of different sizes correctly
## Installation
```toml
[dependencies]
ankurah-virtual-scroll = "0.7"
```
## Usage
### Leptos / Dioxus (Pure Rust)
Use `ScrollManager<V>` directly - no macro needed:
<pre><code transclude="crates/virtual-scroll/tests/readme_example.rs#rust-usage">/// 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(())
}</code></pre>
### React Web (WASM) / React Native (UniFFI)
For JavaScript/TypeScript frontends, use the `generate_scroll_manager!` macro in your bindings crate to generate platform-specific wrappers:
<pre><code transclude="playwright-tests/wasm-bindings/src/lib.rs#generate-macro">// Generate MessageScrollManager with WASM bindings
ankurah_virtual_scroll::generate_scroll_manager!(
Message,
MessageView,
MessageLiveQuery,
timestamp_field = "timestamp"
);</code></pre>
This generates `MessageScrollManager` with the appropriate bindings based on feature flags:
- `wasm` feature: generates `#[wasm_bindgen]` bindings for React web apps
- `uniffi` feature: generates UniFFI bindings for React Native apps (in development)
#### React Component Example
<pre><code transclude="playwright-tests/react-app/src/components/ExampleMessageList.tsx#react-example">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>
)
}</code></pre>
## Modes
- **Live**: At the newest edge, receiving real-time updates with auto-scroll
- **Backward**: User scrolled toward older items, loading historical content
- **Forward**: User scrolling back toward newer items, transitions to Live when reaching the edge
## Architecture
The scroll manager handles:
- Query construction (predicate + cursor + ordering + limit)
- Mode tracking (Live / Backward / Forward)
- Boundary detection (at earliest/latest based on result count)
- Intersection anchoring for scroll stability
Platform layers handle:
- DOM/FlatList binding and scroll events
- Visible item detection (by EntityId)
- Scroll position measurement and adjustment
## Crates
- `ankurah-virtual-scroll` - Core scroll manager implementation
- `ankurah-virtual-scroll-derive` - Derive macro for generating typed scroll managers
## Version Compatibility
Minor versions align with ankurah (e.g., 0.7.x works with ankurah 0.7.x). Patch versions are independent.
## License
MIT OR Apache-2.0