Skip to content
Open
118 changes: 79 additions & 39 deletions packages/pagination/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type Accessor,
batch,
type JSX,
type Resource,
type Setter,
createComputed,
createMemo,
Expand Down Expand Up @@ -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<T[]>`
* @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. `<div ref={loader} />`).
* @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<T> = { page: number; items: T[] };
export function createInfiniteScroll<T>(fetcher: (page: number) => Promise<T[]>): [
pages: Accessor<T[]>,
loader: (el: Element) => void,
pages: Accessor<T[]> & Pick<Resource<T[]>, "loading" | "error">,
loader: (el: Element | null) => void,
options: {
page: Accessor<number>;
setPage: Setter<number>;
setPages: Setter<T[]>;
end: Accessor<boolean>;
setEnd: Setter<boolean>;
refetch: (info?: unknown) => Resp<T> | Promise<Resp<T> | undefined> | null | undefined;
},
] {
const [pages, setPages] = createSignal<T[]>([]);
const [items, setItems] = createSignal<T[]>([]);
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<Resp<T>> => ({
page: p,
items: await fetcher(p),
});

const [contents] = createResource(page, fetcher);
const [res, { refetch }] = createResource<Resp<T>, 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 <Suspense> 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<T[]> & Pick<Resource<T[]>, "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,
},
];
}