Skip to content

fix: issue #795 #797

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 153 additions & 58 deletions packages/pagination/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need to explicitly mention SolidJS here when the whole package is part of solid-primitives.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure will improve this

*
* 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<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
*
* @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<T[]>} 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually use the pattern that we take a params object or a params object signal. If it is a signal, we expect that we should reset state on params change; otherwise, we don't.

* @param {boolean} [options.resetOnParamsChange=true] - Whether to reset state (pages, page, end) when parameters change.
*
* @returns {[Accessor<T[]>, (el: Element) => void, {
* page: Accessor<number>,
* setPage: Setter<number>,
* setPages: Setter<T[]>,
* end: Accessor<boolean>,
* setEnd: Setter<boolean>,
* error: Accessor<Error | null>,
* loading: Accessor<boolean>
* }]} 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<number>} page - Current page number accessor.
* @property {Setter<number>} setPage - Function to manually set the current page number.
* @property {Setter<T[]>} setPages - Function to manually set the entire list of loaded items.
* @property {Accessor<boolean>} end - Accessor that indicates if the end of available content has been reached.
* @property {Setter<boolean>} setEnd - Function to manually set the end state.
* @property {Accessor<Error | null>} error - Accessor for any error encountered during fetching.
* @property {Accessor<boolean>} loading - Accessor for the current loading state.
*/
export function createInfiniteScroll<T>(fetcher: (page: number) => Promise<T[]>): [
pages: Accessor<T[]>,
loader: (el: Element) => void,
options: {
page: Accessor<number>;
setPage: Setter<number>;
setPages: Setter<T[]>;
end: Accessor<boolean>;
setEnd: Setter<boolean>;
},
export function createInfiniteScroll<T, E = unknown, P = unknown>(
fetcher: (page: number, params: P) => Promise<T[]>,
options?: InfiniteScrollOptions<P>
): [
pages: Accessor<T[]>,
loader: (el: Element) => void,
state: {
page: Accessor<number>
setPage: Setter<number>
setPages: Setter<T[]>
end: Accessor<boolean>
setEnd: Setter<boolean>
error: Accessor<Error | null>
loading: Accessor<boolean>
}
] {
const [pages, setPages] = createSignal<T[]>([]);
const [page, setPage] = createSignal(0);
const [end, setEnd] = createSignal(false);
const [pages, setPages] = createSignal<T[]>([])
const [page, setPage] = createSignal(0)
const [end, setEnd] = createSignal(false)
const [error, setError] = createSignal<Error | null>(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
},
]
}