hensei-web/docs/infinite-scroll-implementation.md

764 lines
No EOL
21 KiB
Markdown

# 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<T> {
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<T> {
// Reactive state
items = $state<T[]>([])
page = $state(1)
totalPages = $state<number | undefined>()
total = $state<number | undefined>()
loading = $state(false)
loadingMore = $state(false)
error = $state<AdapterError | undefined>()
// Sentinel element for intersection detection
sentinelElement = $state<HTMLElement | undefined>()
// Viewport detection using Runed
private inViewport: IsInViewport
// Configuration
private fetcher: InfiniteScrollOptions<T>['fetcher']
private threshold: number
private maxItems?: number
// Abort controller for cancellation
private abortController?: AbortController
constructor(options: InfiniteScrollOptions<T>) {
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<T>(
options: InfiniteScrollOptions<T>
): InfiniteScrollResource<T> {
return new InfiniteScrollResource(options)
}
```
### 2. InfiniteScroll Component
Location: `/src/lib/components/InfiniteScroll.svelte`
```svelte
<script lang="ts">
import type { InfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
import type { Snippet } from 'svelte'
interface Props {
resource: InfiniteScrollResource<any>
children: Snippet
loadingSnippet?: Snippet
errorSnippet?: Snippet<[Error]>
emptySnippet?: Snippet
endSnippet?: Snippet
class?: string
}
const {
resource,
children,
loadingSnippet,
errorSnippet,
emptySnippet,
endSnippet,
class: className = ''
}: Props = $props()
// Bind sentinel element
let sentinel: HTMLElement
$effect(() => {
if (sentinel) {
resource.bindSentinel(sentinel)
}
})
// Cleanup on unmount
$effect(() => {
return () => resource.destroy()
})
// Accessibility: Announce new content
$effect(() => {
if (resource.loadingMore) {
announceToScreenReader('Loading more items...')
}
})
function announceToScreenReader(message: string) {
const announcement = document.createElement('div')
announcement.setAttribute('role', 'status')
announcement.setAttribute('aria-live', 'polite')
announcement.className = 'sr-only'
announcement.textContent = message
document.body.appendChild(announcement)
setTimeout(() => announcement.remove(), 1000)
}
</script>
<div class="infinite-scroll-container {className}">
<!-- Main content -->
{@render children()}
<!-- Loading indicator for initial load -->
{#if resource.loading}
{#if loadingSnippet}
{@render loadingSnippet()}
{:else}
<div class="loading-initial">
<span class="spinner"></span>
Loading...
</div>
{/if}
{/if}
<!-- Empty state -->
{#if resource.isEmpty && !resource.loading}
{#if emptySnippet}
{@render emptySnippet()}
{:else}
<div class="empty-state">No items found</div>
{/if}
{/if}
<!-- Sentinel element for intersection observer -->
{#if !resource.loading && resource.hasMore}
<div
bind:this={sentinel}
class="sentinel"
aria-hidden="true"
></div>
{/if}
<!-- Loading more indicator -->
{#if resource.loadingMore}
<div class="loading-more">
<span class="spinner"></span>
Loading more...
</div>
{/if}
<!-- Error state with retry -->
{#if resource.error && !resource.loadingMore}
{#if errorSnippet}
{@render errorSnippet(resource.error)}
{:else}
<div class="error-state">
<p>Failed to load more items</p>
<button onclick={() => resource.retry()}>
Try Again
</button>
</div>
{/if}
{/if}
<!-- End of list indicator -->
{#if !resource.hasMore && !resource.isEmpty}
{#if endSnippet}
{@render endSnippet()}
{:else}
<div class="end-state">No more items to load</div>
{/if}
{/if}
<!-- Fallback load more button for accessibility -->
{#if resource.hasMore && !resource.loadingMore && !resource.loading}
<button
class="load-more-fallback"
onclick={() => resource.loadMore()}
aria-label="Load more items"
>
Load More
</button>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
.infinite-scroll-container {
position: relative;
}
.sentinel {
height: 1px;
margin-top: -200px; // Trigger before reaching actual end
}
.loading-initial,
.loading-more,
.error-state,
.empty-state,
.end-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $unit-4x;
text-align: center;
}
.spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.load-more-fallback {
display: block;
margin: $unit-2x auto;
padding: $unit $unit-2x;
background: var(--button-bg);
color: var(--button-text);
border: 1px solid var(--button-border);
border-radius: 4px;
cursor: pointer;
// Only show for keyboard/screen reader users
&:not(:focus) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
```
### 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<Party>({
fetcher: async (page) => {
return await this.adapter.list({ page })
},
debounceMs: 200,
threshold: 300
})
// Infinite scroll for user parties
userPartiesInfinite = createInfiniteScrollResource<Party>({
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
<script lang="ts">
import type { PageData } from './$types'
import { InfiniteScroll } from '$lib/components/InfiniteScroll.svelte'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { createInfiniteScrollResource } from '$lib/api/adapters/resources'
import { userAdapter } from '$lib/api/adapters'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
const { data } = $props() as { data: PageData }
// Create infinite scroll resource
const profileResource = createInfiniteScrollResource({
fetcher: async (page) => {
const tab = data.tab || 'teams'
if (tab === 'favorites' && data.isOwner) {
return await userAdapter.getFavorites({ page })
}
return await userAdapter.getProfile(data.user.username, page)
},
initialData: data.items,
debounceMs: 200
})
// Initialize with SSR data
$effect(() => {
if (data.items && profileResource.items.length === 0) {
profileResource.items = data.items
profileResource.page = data.page || 1
profileResource.totalPages = data.totalPages
profileResource.total = data.total
}
})
</script>
<section class="profile">
<header class="header">
<!-- Header content unchanged -->
</header>
<InfiniteScroll resource={profileResource}>
<ExploreGrid items={profileResource.items} />
{#snippet emptySnippet()}
<p class="empty">No teams found</p>
{/snippet}
{#snippet endSnippet()}
<p class="end-message">You've reached the end!</p>
{/snippet}
</InfiniteScroll>
</section>
```
#### Explore Page (`/src/routes/teams/explore/+page.svelte`)
```svelte
<script lang="ts">
import type { PageData } from './$types'
import { InfiniteScroll } from '$lib/components/InfiniteScroll.svelte'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { createInfiniteScrollResource } from '$lib/api/adapters/resources'
import { partyAdapter } from '$lib/api/adapters'
const { data } = $props() as { data: PageData }
// Create infinite scroll resource
const exploreResource = createInfiniteScrollResource({
fetcher: (page) => partyAdapter.list({ page }),
initialData: data.items,
pageSize: 20,
maxItems: 200 // Limit for performance
})
// Initialize with SSR data
$effect(() => {
if (data.items && exploreResource.items.length === 0) {
exploreResource.items = data.items
exploreResource.page = data.page || 1
exploreResource.totalPages = data.totalPages
exploreResource.total = data.total
}
})
</script>
<section class="explore">
<header>
<h1>Explore Teams</h1>
</header>
<InfiniteScroll resource={exploreResource} class="explore-grid">
<ExploreGrid items={exploreResource.items} />
</InfiniteScroll>
</section>
```
## 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}
<InfiniteScroll>...</InfiniteScroll>
{:else}
<Pagination>...</Pagination>
{/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.