diff --git a/.changeset/pink-carpets-reply.md b/.changeset/pink-carpets-reply.md new file mode 100644 index 000000000..3137bbe42 --- /dev/null +++ b/.changeset/pink-carpets-reply.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/pagination": minor +--- + +createInfiniteScroll now exposes .loading/.error on pages, handles null refs, diff --git a/packages/pagination/src/index.ts b/packages/pagination/src/index.ts index bc5c9704f..cb98a45e4 100644 --- a/packages/pagination/src/index.ts +++ b/packages/pagination/src/index.ts @@ -3,6 +3,7 @@ import { type Accessor, batch, type JSX, + type Resource, type Setter, createComputed, createMemo, @@ -350,74 +351,113 @@ declare module "solid-js" { } } -export type _E = JSX.Element; - /** * Provides an easy way to implement infinite scrolling. * * ```ts - * const [pages, loader, { page, setPage, setPages, end, setEnd }] = createInfiniteScroll(fetcher); + * const [pages, loader, { page, setPage, setPages, end, setEnd }] = + * createInfiniteScroll(fetcher); * ``` * @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 + * @return `pages` an accessor over the concatenated items array. The accessor + * also carries reactive `loading` and `error` fields forwarded from the + * underlying `solid-js` `Resource`, so their behavior matches any other + * resource in a Solid app. + * @return `loader` ref-callback for your sentinel element (e.g. `
`). + * @method `page` current page index accessor. + * @method `setPage` manually change the page. + * @method `setPages` replace the entire concatenated items array. + * @method `end` whether we've reached the end (fetch returned empty). + * @method `setEnd` manually set end. + * @method `refetch` imperatively refetch data. */ +type Resp = { page: number; items: T[] }; export function createInfiniteScroll(fetcher: (page: number) => Promise): [ - pages: Accessor, - loader: (el: Element) => void, + pages: Accessor & Pick, "loading" | "error">, + loader: (el: Element | null) => void, options: { page: Accessor; setPage: Setter; setPages: Setter; end: Accessor; setEnd: Setter; + refetch: (info?: unknown) => Resp | Promise | undefined> | null | undefined; }, ] { - const [pages, setPages] = createSignal([]); + const [items, setItems] = createSignal([]); const [page, setPage] = createSignal(0); const [end, setEnd] = createSignal(false); - 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)); - }; - } + // Tag each response with the page it came from so we can ignore stale/duplicate appends + const wrapped = async (p: number): Promise> => ({ + page: p, + items: await fetcher(p), + }); - const [contents] = createResource(page, fetcher); + const [res, { refetch }] = createResource, number>(page, wrapped); + let lastAppended = -1; createComputed(() => { - const content = contents.latest; - if (!content) return; + // Read via `.latest` so a changing source signal doesn't propagate + // suspense to an enclosing boundary on every page change. + const r = res.latest; + if (!r) return; batch(() => { - if (content.length === 0) setEnd(true); - setPages(p => [...p, ...content]); + if (r.items.length === 0) { + setEnd(true); + return; + } + if (r.page !== lastAppended) { + setItems(prev => [...prev, ...r.items]); + lastAppended = r.page; + } }); }); + let io: IntersectionObserver | null = null; + let observed: Element | null = null; + const loader = (el: Element | null) => { + if (isServer) return; + if (observed && io) { + io.unobserve(observed); + observed = null; + } + if (!io) { + io = new IntersectionObserver( + entries => { + if (!entries.some(e => e.isIntersecting)) return; + if (end() || res.loading) return; + setPage(p => p + 1); + }, + { root: null, rootMargin: "0px 0px 50px 0px", threshold: 0 }, + ); + onCleanup(() => { + io?.disconnect(); + io = null; + }); + } + if (el) { + io.observe(el); + observed = el; + } + }; + + const pages = (() => items()) as Accessor & Pick, "loading" | "error">; + Object.defineProperties(pages, { + loading: { get: () => res.loading, enumerable: true }, + error: { get: () => res.error, enumerable: true }, + }); + return [ pages, - add, + loader, { - page: page, - setPage: setPage, - setPages: setPages, - end: end, - setEnd: setEnd, + page, + setPage, + setPages: setItems, + end, + setEnd, + refetch, }, ]; }