add useInfiniteLoader composable for state-gated infinite scroll
This commit is contained in:
parent
503f121324
commit
26299d5d10
1 changed files with 304 additions and 0 deletions
304
src/lib/stores/loaderState.svelte.ts
Normal file
304
src/lib/stores/loaderState.svelte.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/**
|
||||
* Infinite Scroll State Management
|
||||
*
|
||||
* Inspired by svelte-infinite (https://github.com/ndom91/svelte-infinite)
|
||||
* Provides state gating to prevent rapid-fire page fetches that can crash Svelte's block tracking.
|
||||
*
|
||||
* @module stores/loaderState
|
||||
*/
|
||||
|
||||
import type { CreateInfiniteQueryResult } from '@tanstack/svelte-query'
|
||||
import { IsInViewport } from 'runed'
|
||||
|
||||
export const STATUS = {
|
||||
READY: 'READY',
|
||||
LOADING: 'LOADING',
|
||||
COMPLETE: 'COMPLETE',
|
||||
ERROR: 'ERROR'
|
||||
} as const
|
||||
|
||||
export type LoaderStatus = (typeof STATUS)[keyof typeof STATUS]
|
||||
|
||||
/**
|
||||
* Simple state machine for infinite scroll loading.
|
||||
*
|
||||
* States:
|
||||
* - READY: Can accept a new load request
|
||||
* - LOADING: Currently fetching data
|
||||
* - COMPLETE: No more data to load
|
||||
* - ERROR: Last load failed (allows retry)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const loaderState = new LoaderState()
|
||||
*
|
||||
* // Before loading
|
||||
* if (loaderState.status === STATUS.READY) {
|
||||
* loaderState.status = STATUS.LOADING
|
||||
* await fetchData()
|
||||
* loaderState.loaded()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class LoaderState {
|
||||
isFirstLoad = $state(true)
|
||||
status = $state<LoaderStatus>(STATUS.READY)
|
||||
|
||||
/**
|
||||
* Call after a successful load to allow the next fetch.
|
||||
*/
|
||||
loaded = () => {
|
||||
if (this.isFirstLoad) this.isFirstLoad = false
|
||||
this.status = STATUS.READY
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when there's no more data to load.
|
||||
*/
|
||||
complete = () => {
|
||||
if (this.isFirstLoad) this.isFirstLoad = false
|
||||
this.status = STATUS.COMPLETE
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when a load fails.
|
||||
*/
|
||||
error = () => {
|
||||
this.status = STATUS.ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to initial state (e.g., when filters change).
|
||||
*/
|
||||
reset = () => {
|
||||
this.isFirstLoad = true
|
||||
this.status = STATUS.READY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects infinite loops and triggers cooldown.
|
||||
*
|
||||
* If too many load attempts happen in a short period, assumes something is wrong
|
||||
* and pauses loading to prevent browser crashes.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const loopTracker = new LoopTracker()
|
||||
*
|
||||
* // After each successful load
|
||||
* loopTracker.track()
|
||||
*
|
||||
* // Check before loading
|
||||
* if (!loopTracker.coolingOff) {
|
||||
* await fetchData()
|
||||
* }
|
||||
*
|
||||
* // Cleanup on component destroy
|
||||
* onDestroy(() => loopTracker.destroy())
|
||||
* ```
|
||||
*/
|
||||
export class LoopTracker {
|
||||
coolingOff = $state(false)
|
||||
#coolingOffTimer: ReturnType<typeof setTimeout> | null = null
|
||||
#timer: ReturnType<typeof setTimeout> | null = null
|
||||
#count = 0
|
||||
|
||||
constructor(
|
||||
private loopMaxCalls = 5,
|
||||
private loopDetectionTimeout = 2000,
|
||||
private loopCooldown = 3000
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Call after each successful load to track frequency.
|
||||
*/
|
||||
track() {
|
||||
this.#count += 1
|
||||
|
||||
// Reset timer - if no calls in loopDetectionTimeout, reset count
|
||||
if (this.#timer) clearTimeout(this.#timer)
|
||||
this.#timer = setTimeout(() => {
|
||||
this.#count = 0
|
||||
}, this.loopDetectionTimeout)
|
||||
|
||||
// If too many calls, start cooldown
|
||||
if (this.#count >= this.loopMaxCalls) {
|
||||
console.warn(`[LoopTracker] Too many load attempts (${this.#count}), cooling off...`)
|
||||
this.coolingOff = true
|
||||
this.#coolingOffTimer = setTimeout(() => {
|
||||
this.coolingOff = false
|
||||
this.#count = 0
|
||||
}, this.loopCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timers. Call in onDestroy.
|
||||
*/
|
||||
destroy() {
|
||||
if (this.#timer) clearTimeout(this.#timer)
|
||||
if (this.#coolingOffTimer) clearTimeout(this.#coolingOffTimer)
|
||||
}
|
||||
}
|
||||
|
||||
interface InfiniteLoaderOptions {
|
||||
/** Root margin for intersection observer (default: '100px') */
|
||||
rootMargin?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for state-gated infinite scroll with TanStack Query.
|
||||
*
|
||||
* Encapsulates the LoaderState, LoopTracker, intersection observer, and all
|
||||
* reactive effects to reduce duplication across collection pages.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
|
||||
*
|
||||
* let sentinelEl = $state<HTMLElement>()
|
||||
*
|
||||
* const collectionQuery = createInfiniteQuery(...)
|
||||
* const loader = useInfiniteLoader(
|
||||
* () => collectionQuery,
|
||||
* () => sentinelEl
|
||||
* )
|
||||
*
|
||||
* // Cleanup
|
||||
* onDestroy(() => loader.destroy())
|
||||
* </script>
|
||||
*
|
||||
* <div bind:this={sentinelEl}></div>
|
||||
* ```
|
||||
*/
|
||||
export function useInfiniteLoader<TData, TError>(
|
||||
queryFn: () => CreateInfiniteQueryResult<TData, TError>,
|
||||
sentinelFn: () => HTMLElement | undefined,
|
||||
options?: InfiniteLoaderOptions
|
||||
) {
|
||||
const state = new LoaderState()
|
||||
const loopTracker = new LoopTracker()
|
||||
|
||||
// Set up intersection observer for the sentinel element
|
||||
const inViewport = new IsInViewport(sentinelFn, {
|
||||
rootMargin: options?.rootMargin ?? '100px'
|
||||
})
|
||||
|
||||
// Track whether the sentinel has left the viewport since the last load
|
||||
// This prevents the $effect from immediately triggering another load
|
||||
// when the sentinel is still in viewport after a page loads
|
||||
let waitingForSentinelExit = $state(false)
|
||||
|
||||
// Trigger load when sentinel enters viewport and we're ready
|
||||
$effect(() => {
|
||||
if (inViewport.current && state.status === STATUS.READY && !waitingForSentinelExit) {
|
||||
loadMore()
|
||||
}
|
||||
})
|
||||
|
||||
// Clear the waiting flag when sentinel leaves viewport
|
||||
$effect(() => {
|
||||
if (!inViewport.current && waitingForSentinelExit) {
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} Sentinel exited viewport, ready for next trigger`)
|
||||
waitingForSentinelExit = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Attempt to load the next page.
|
||||
* Respects state gating and loop detection.
|
||||
*/
|
||||
async function loadMore() {
|
||||
const query = queryFn()
|
||||
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} loadMore called, status=${state.status}, waitingForExit=${waitingForSentinelExit}`)
|
||||
|
||||
// Guard: Only proceed if READY or ERROR (for retry)
|
||||
if (
|
||||
state.status === STATUS.COMPLETE ||
|
||||
(state.status !== STATUS.READY && state.status !== STATUS.ERROR)
|
||||
) {
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} Skipped - status is ${state.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Guard: Wait for sentinel to leave viewport after a load before allowing next
|
||||
if (waitingForSentinelExit) {
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} Skipped - waiting for sentinel to leave viewport`)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if cooling off from loop detection
|
||||
if (loopTracker.coolingOff) {
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} Skipped - cooling off`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there's more data to load
|
||||
if (!query.hasNextPage) {
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} No more pages available`)
|
||||
state.complete()
|
||||
return
|
||||
}
|
||||
|
||||
state.status = STATUS.LOADING
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} Status set to LOADING, starting fetch...`)
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
// Await the fetch - blocks until complete
|
||||
await query.fetchNextPage()
|
||||
|
||||
// Log page load
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pageCount = ((query.data as any)?.pages?.length ?? 0)
|
||||
const elapsed = (performance.now() - startTime).toFixed(0)
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} Loaded page ${pageCount} (${elapsed}ms)`)
|
||||
|
||||
// Track AFTER successful load (svelte-infinite pattern)
|
||||
loopTracker.track()
|
||||
|
||||
// Auto-transition to READY or COMPLETE
|
||||
if (state.status === STATUS.LOADING) {
|
||||
if (!query.hasNextPage) {
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} Complete - no more pages`)
|
||||
state.complete()
|
||||
} else {
|
||||
// Set flag to wait for sentinel to leave viewport before next load
|
||||
// This prevents immediate re-triggering when sentinel is still visible
|
||||
waitingForSentinelExit = true
|
||||
console.log(`[InfiniteLoader] ${new Date().toISOString()} Ready for next page (waiting for sentinel exit)`)
|
||||
state.loaded()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[InfiniteLoader] ${new Date().toISOString()} Failed to load next page:`, error)
|
||||
state.error()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the loader state (e.g., when filters change).
|
||||
*/
|
||||
function reset() {
|
||||
state.reset()
|
||||
waitingForSentinelExit = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timers. Call in onDestroy.
|
||||
*/
|
||||
function destroy() {
|
||||
loopTracker.destroy()
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
loopTracker,
|
||||
reset,
|
||||
destroy
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue