ankurah-virtual-scroll-derive

Crates.ioankurah-virtual-scroll-derive
lib.rsankurah-virtual-scroll-derive
version0.7.7
created_at2026-01-13 04:03:32.942258+00
updated_at2026-01-23 00:48:32.45227+00
descriptionDerive macro for generating typed scroll managers for Ankurah
homepage
repositoryhttps://github.com/ankurah/virtual-scroll
max_upload_size
id2039296
size42,597
Daniel (dnorman)

documentation

README

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

[dependencies]
ankurah-virtual-scroll = "0.7"

Usage

Leptos / Dioxus (Pure Rust)

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(())
}

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:

// 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 apps
  • uniffi feature: generates UniFFI bindings for React Native apps (in development)

React Component 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>
  )
}

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

Commit count: 20

cargo fmt