# Infinite Scrolling Implementation with Runed ## Overview This document outlines the implementation of infinite scrolling for the Hensei application using Runed's utilities instead of TanStack Query. Runed provides Svelte 5-specific reactive utilities that work seamlessly with runes. ## Current State Analysis ### What We Have - **Runed v0.31.1** installed and actively used in the project - Established resource patterns (`search.resource.svelte.ts`, `party.resource.svelte.ts`) - Pagination with "Previous/Next" links on profile and explore pages - API support for pagination via `page` parameter - SSR with SvelteKit for initial page loads ### What We Need - Automatic loading of next page when user scrolls near bottom - Seamless data accumulation without page refreshes - Loading indicators and error states - Memory-efficient data management - Accessibility support ## Architecture Design ### Core Components ``` ┌─────────────────────────────────────────────────┐ │ InfiniteScroll Component │ │ - Sentinel element for intersection detection │ │ - Loading/error UI states │ │ - Accessibility features │ └──────────────────┬──────────────────────────────┘ │ ┌──────────────────▼──────────────────────────────┐ │ InfiniteScrollResource Class │ │ - IsInViewport/useIntersectionObserver │ │ - State management with $state runes │ │ - Page tracking and data accumulation │ │ - Loading/error state handling │ └──────────────────┬──────────────────────────────┘ │ ┌──────────────────▼──────────────────────────────┐ │ Existing Adapters (Enhanced) │ │ - PartyAdapter │ │ - UserAdapter │ │ - Support for incremental data fetching │ └──────────────────────────────────────────────────┘ ``` ## Implementation Details ### 1. InfiniteScrollResource Class Location: `/src/lib/api/adapters/resources/infiniteScroll.resource.svelte.ts` ```typescript import { IsInViewport, watch, useDebounce } from 'runed' import type { AdapterError } from '../types' export interface InfiniteScrollOptions { fetcher: (page: number) => Promise<{ results: T[] page: number totalPages: number total: number }> initialData?: T[] pageSize?: number threshold?: number // pixels before viewport edge to trigger load debounceMs?: number maxItems?: number // optional limit for memory management } export class InfiniteScrollResource { // Reactive state items = $state([]) page = $state(1) totalPages = $state() total = $state() loading = $state(false) loadingMore = $state(false) error = $state() // Sentinel element for intersection detection sentinelElement = $state() // Viewport detection using Runed private inViewport: IsInViewport // Configuration private fetcher: InfiniteScrollOptions['fetcher'] private threshold: number private maxItems?: number // Abort controller for cancellation private abortController?: AbortController constructor(options: InfiniteScrollOptions) { this.fetcher = options.fetcher this.threshold = options.threshold ?? 200 this.maxItems = options.maxItems if (options.initialData) { this.items = options.initialData } // Set up viewport detection this.inViewport = new IsInViewport( () => this.sentinelElement, { rootMargin: `${this.threshold}px` } ) // Create debounced load function if specified const loadMoreFn = options.debounceMs ? useDebounce(() => this.loadMore(), () => options.debounceMs!) : () => this.loadMore() // Watch for visibility changes watch( () => this.inViewport.current, (isVisible) => { if (isVisible && !this.loading && !this.loadingMore && this.hasMore) { loadMoreFn() } } ) } // Computed properties get hasMore() { return this.totalPages === undefined || this.page < this.totalPages } get isEmpty() { return this.items.length === 0 && !this.loading } // Load initial data or reset async load() { this.reset() this.loading = true this.error = undefined try { const response = await this.fetcher(1) this.items = response.results this.page = response.page this.totalPages = response.totalPages this.total = response.total } catch (err) { this.error = err as AdapterError } finally { this.loading = false } } // Load next page async loadMore() { if (!this.hasMore || this.loadingMore || this.loading) return this.loadingMore = true this.error = undefined // Cancel previous request if any this.abortController?.abort() this.abortController = new AbortController() try { const nextPage = this.page + 1 const response = await this.fetcher(nextPage) // Append new items this.items = [...this.items, ...response.results] // Trim items if max limit is set if (this.maxItems && this.items.length > this.maxItems) { this.items = this.items.slice(-this.maxItems) } this.page = response.page this.totalPages = response.totalPages this.total = response.total } catch (err: any) { if (err.name !== 'AbortError') { this.error = err as AdapterError } } finally { this.loadingMore = false this.abortController = undefined } } // Manual trigger for load more (fallback button) async retry() { if (this.error) { await this.loadMore() } } // Reset to initial state reset() { this.items = [] this.page = 1 this.totalPages = undefined this.total = undefined this.loading = false this.loadingMore = false this.error = undefined this.abortController?.abort() } // Bind sentinel element bindSentinel(element: HTMLElement) { this.sentinelElement = element } // Cleanup destroy() { this.abortController?.abort() this.inViewport.stop() } } // Factory function export function createInfiniteScrollResource( options: InfiniteScrollOptions ): InfiniteScrollResource { return new InfiniteScrollResource(options) } ``` ### 2. InfiniteScroll Component Location: `/src/lib/components/InfiniteScroll.svelte` ```svelte
{@render children()} {#if resource.loading} {#if loadingSnippet} {@render loadingSnippet()} {:else}
Loading...
{/if} {/if} {#if resource.isEmpty && !resource.loading} {#if emptySnippet} {@render emptySnippet()} {:else}
No items found
{/if} {/if} {#if !resource.loading && resource.hasMore} {/if} {#if resource.loadingMore}
Loading more...
{/if} {#if resource.error && !resource.loadingMore} {#if errorSnippet} {@render errorSnippet(resource.error)} {:else}

Failed to load more items

{/if} {/if} {#if !resource.hasMore && !resource.isEmpty} {#if endSnippet} {@render endSnippet()} {:else}
No more items to load
{/if} {/if} {#if resource.hasMore && !resource.loadingMore && !resource.loading} {/if}
``` ### 3. Enhanced Party Resource Location: Update `/src/lib/api/adapters/resources/party.resource.svelte.ts` Add infinite scroll support to existing PartyResource: ```typescript // Add to existing PartyResource class // Infinite scroll for explore/gallery exploreInfinite = createInfiniteScrollResource({ fetcher: async (page) => { return await this.adapter.list({ page }) }, debounceMs: 200, threshold: 300 }) // Infinite scroll for user parties userPartiesInfinite = createInfiniteScrollResource({ fetcher: async (page) => { const username = this.currentUsername // store username when loading return await this.adapter.listUserParties({ username, page }) }, debounceMs: 200, threshold: 300 }) ``` ### 4. Usage in Routes #### Profile Page (`/src/routes/[username]/+page.svelte`) ```svelte
{#snippet emptySnippet()}

No teams found

{/snippet} {#snippet endSnippet()}

You've reached the end!

{/snippet}
``` #### Explore Page (`/src/routes/teams/explore/+page.svelte`) ```svelte

Explore Teams

``` ## Performance Optimizations ### 1. Virtual Scrolling (Optional) For extremely large lists (>500 items), consider implementing virtual scrolling: - Use a library like `@tanstack/virtual` or build custom with Svelte - Only render visible items + buffer - Maintain scroll position with placeholder elements ### 2. Memory Management - Set `maxItems` limit to prevent unbounded growth - Implement "windowing" - keep only N pages in memory - Clear old pages when scrolling forward ### 3. Request Optimization - Debounce scroll events (built-in with `debounceMs`) - Cancel in-flight requests when component unmounts - Implement request deduplication ### 4. Caching Strategy - Cache fetched pages in adapter layer - Implement stale-while-revalidate pattern - Clear cache on user actions (create, update, delete) ## Accessibility Features ### 1. Keyboard Navigation - Hidden "Load More" button accessible via Tab - Focus management when new content loads - Skip links to bypass loaded content ### 2. Screen Reader Support - Announce when new content is loading - Announce when new content has loaded - Announce total item count - Announce when end is reached ### 3. Reduced Motion ```css @media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: 0.8; } } ``` ### 4. ARIA Attributes - `role="status"` for loading indicators - `aria-live="polite"` for announcements - `aria-busy="true"` during loading - `aria-label` for interactive elements ## Testing Strategy ### 1. Unit Tests ```typescript import { describe, it, expect } from 'vitest' import { InfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte' describe('InfiniteScrollResource', () => { it('loads initial data', async () => { const resource = createInfiniteScrollResource({ fetcher: mockFetcher, initialData: mockData }) expect(resource.items).toEqual(mockData) }) it('loads more when triggered', async () => { const resource = createInfiniteScrollResource({ fetcher: mockFetcher }) await resource.load() const initialCount = resource.items.length await resource.loadMore() expect(resource.items.length).toBeGreaterThan(initialCount) }) it('stops loading when no more pages', async () => { // Test hasMore property }) it('handles errors gracefully', async () => { // Test error states }) }) ``` ### 2. Integration Tests - Test scroll trigger at various speeds - Test with slow network (throttling) - Test error recovery - Test memory limits - Test accessibility features ### 3. E2E Tests ```typescript test('infinite scroll loads more content', async ({ page }) => { await page.goto('/teams/explore') // Initial content should be visible await expect(page.locator('.grid-item')).toHaveCount(20) // Scroll to bottom await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) // Wait for more content to load await page.waitForSelector('.grid-item:nth-child(21)') // Verify more items loaded const itemCount = await page.locator('.grid-item').count() expect(itemCount).toBeGreaterThan(20) }) ``` ## Migration Path ### Phase 1: Infrastructure 1. Create InfiniteScrollResource class 2. Create InfiniteScroll component 3. Write unit tests ### Phase 2: Implementation 1. Update explore page (lowest risk) 2. Update profile pages 3. Update other paginated lists ### Phase 3: Optimization 1. Add virtual scrolling if needed 2. Implement advanced caching 3. Performance monitoring ### Phase 4: Polish 1. Refine loading indicators 2. Enhance error states 3. Improve accessibility 4. Add analytics ## Rollback Plan If infinite scrolling causes issues: 1. Keep pagination code in place (commented) 2. Use feature flag to toggle between pagination and infinite scroll 3. Can revert per-route if needed ```typescript const useInfiniteScroll = $state( localStorage.getItem('feature:infinite-scroll') !== 'false' ) {#if useInfiniteScroll} ... {:else} ... {/if} ``` ## Benefits Over TanStack Query 1. **Native Svelte 5**: Built specifically for Svelte runes 2. **Simpler API**: No provider setup required 3. **Smaller Bundle**: Runed is lightweight 4. **Better Integration**: Works seamlessly with SvelteKit 5. **Type Safety**: Full TypeScript support with runes ## Potential Challenges 1. **SSR Hydration**: Ensure client picks up where server left off 2. **Back Navigation**: Restore scroll position to correct item 3. **Memory Leaks**: Proper cleanup of observers and listeners 4. **Race Conditions**: Handle rapid scrolling/navigation 5. **Error Recovery**: Graceful handling of network failures ## References - [Runed Documentation](https://runed.dev/docs) - [Runed IsInViewport](https://runed.dev/docs/utilities/is-in-viewport) - [Runed useIntersectionObserver](https://runed.dev/docs/utilities/use-intersection-observer) - [Runed Resource Pattern](https://runed.dev/docs/utilities/resource) - [Svelte 5 Runes](https://svelte.dev/docs/svelte/runes) - [IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) ## Conclusion This implementation leverages Runed's powerful utilities to create a robust, accessible, and performant infinite scrolling solution that integrates seamlessly with the existing Hensei application architecture. The approach follows established patterns in the codebase while adding modern UX improvements.