diff --git a/packages/pagination/src/index.ts b/packages/pagination/src/index.ts index 8e2bc7bb6..85aa7eae1 100644 --- a/packages/pagination/src/index.ts +++ b/packages/pagination/src/index.ts @@ -246,71 +246,166 @@ declare module "solid-js" { export type _E = JSX.Element; /** - * Provides an easy way to implement infinite scrolling. + * A SolidJS utility to create an infinite scrolling experience with IntersectionObserver and reactivity. * + * This function handles page fetching, auto-incrementing on scroll, error tracking, and supports + * dynamic parameters for API requests. It returns an accessor for the loaded pages, a directive + * to attach to a loader DOM element, and control state for pagination. + * + * ### Usage Example: * ```ts - * const [pages, loader, { page, setPage, setPages, end, setEnd }] = createInfiniteScroll(fetcher); + * const [pages, loader, { page, setPage, setPages, end, setEnd }] = createInfiniteScroll(fetcher, { + * params: () => ({ query: 'search-term' }), + * resetOnParamsChange: true + * }); * ``` - * @param fetcher `(page: number) => Promise` - * @return `pages()` is an accessor contains array of contents - * @property `pages.loading` is a boolean indicator for the loading state - * @property `pages.error` contains any error encountered - * @return `infiniteScrollLoader` is a directive used to set the loader element - * @method `page` is an accessor that contains page number - * @method `setPage` allows to manually change the page number - * @method `setPages` allows to manually change the contents of the page - * @method `end` is a boolean indicator for end of the page - * @method `setEnd` allows to manually change the end + * + * @template T - The type of individual page items (e.g., object, string). + * @template E - (Optional) The error type. Defaults to `unknown`. + * @template P - (Optional) The type of dynamic fetcher parameters. + * + * @param {function(page: number, params: P): Promise} fetcher + * A function that takes a page number and optional parameters and returns a Promise resolving to an array of items. + * + * @param {Object} [options] - Optional configuration object. + * @param {() => P} [options.params] - A reactive accessor for dynamic fetcher parameters. + * @param {boolean} [options.resetOnParamsChange=true] - Whether to reset state (pages, page, end) when parameters change. + * + * @returns {[Accessor, (el: Element) => void, { + * page: Accessor, + * setPage: Setter, + * setPages: Setter, + * end: Accessor, + * setEnd: Setter, + * error: Accessor, + * loading: Accessor + * }]} A tuple: + * - `pages`: an accessor for the full list of loaded items. + * - `loader`: a directive function that should be attached to the DOM element used as the intersection trigger (e.g., a loading spinner). + * - `state`: an object with methods and accessors for managing pagination state. + * + * @property {Accessor} page - Current page number accessor. + * @property {Setter} setPage - Function to manually set the current page number. + * @property {Setter} setPages - Function to manually set the entire list of loaded items. + * @property {Accessor} end - Accessor that indicates if the end of available content has been reached. + * @property {Setter} setEnd - Function to manually set the end state. + * @property {Accessor} error - Accessor for any error encountered during fetching. + * @property {Accessor} loading - Accessor for the current loading state. */ -export function createInfiniteScroll(fetcher: (page: number) => Promise): [ - pages: Accessor, - loader: (el: Element) => void, - options: { - page: Accessor; - setPage: Setter; - setPages: Setter; - end: Accessor; - setEnd: Setter; - }, +export function createInfiniteScroll( + fetcher: (page: number, params: P) => Promise, + options?: InfiniteScrollOptions

+): [ + pages: Accessor, + loader: (el: Element) => void, + state: { + page: Accessor + setPage: Setter + setPages: Setter + end: Accessor + setEnd: Setter + error: Accessor + loading: Accessor + } ] { - const [pages, setPages] = createSignal([]); - const [page, setPage] = createSignal(0); - const [end, setEnd] = createSignal(false); + const [pages, setPages] = createSignal([]) + const [page, setPage] = createSignal(0) + const [end, setEnd] = createSignal(false) + const [error, setError] = createSignal(null) - let add: (el: Element) => void = noop; - if (!isServer) { - const io = new IntersectionObserver(e => { - if (e.length > 0 && e[0]!.isIntersecting && !end() && !contents.loading) { - setPage(p => p + 1); - } - }); - onCleanup(() => io.disconnect()); - add = (el: Element) => { - io.observe(el); - tryOnCleanup(() => io.unobserve(el)); - }; - } + // Fix: Handle undefined options by providing defaults + const { params, resetOnParamsChange = true } = options || {} - const [contents] = createResource(page, fetcher); + const [contents] = createResource( + () => [page(), params?.()] as const, + async ([p, param]) => { + setError(null) + try { + const result = await fetcher(p, param) + return result + } catch (e) { + setError(e as Error) + console.error(e) + return [] + } + } + ) - createComputed(() => { - const content = contents.latest; - if (!content) return; - batch(() => { - if (content.length === 0) setEnd(true); - setPages(p => [...p, ...content]); - }); - }); + createComputed(() => { + const content = contents() + if (!content) return + batch(() => { + if (content.length === 0) setEnd(true) + setPages((prev) => [...prev, ...content]) + }) + }) - return [ - pages, - add, - { - page: page, - setPage: setPage, - setPages: setPages, - end: end, - setEnd: setEnd, - }, - ]; + // Reset state if params change + createComputed(() => { + if (params) { + params() + if (resetOnParamsChange) { + batch(() => { + setPages([]) + setPage(0) + setEnd(false) + }) + } + } + }) + + let add: (el: Element) => void = () => {} + let currentObservedElement: Element | null = null + + if (!isServer) { + const io = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if ( + entry?.isIntersecting && + !end() && + !contents.loading + ) { + setPage((p) => p + 1) + } + }, + { + rootMargin: '50px' + } + ) + + onCleanup(() => io.disconnect()) + + add = (el: Element) => { + // Unobserve the previous element if it exists + if (currentObservedElement) { + io.unobserve(currentObservedElement) + } + + // Observe the new last element + io.observe(el) + currentObservedElement = el + + tryOnCleanup(() => { + io.unobserve(el) + if (currentObservedElement === el) { + currentObservedElement = null + } + }) + } + } + + return [ + pages, + add, + { + page, + setPage, + setPages, + end, + setEnd, + error, + loading: () => contents.loading + }, + ] }