diff --git a/.changeset/tough-rings-shout.md b/.changeset/tough-rings-shout.md new file mode 100644 index 00000000..fc5c8a27 --- /dev/null +++ b/.changeset/tough-rings-shout.md @@ -0,0 +1,5 @@ +--- +"@supabase-cache-helpers/postgrest-swr": patch +--- + +Keep pagination and infinite-scroll controls available for already-loaded stale data while SWR revalidates, and hide them only while a newly requested page has not loaded yet. diff --git a/docs/content/postgrest/queries.md b/docs/content/postgrest/queries.md index f30e8afe..1541c941 100644 --- a/docs/content/postgrest/queries.md +++ b/docs/content/postgrest/queries.md @@ -85,7 +85,7 @@ Wrapper around the default data fetching hook that returns the query including t Wrapper around the infinite hooks that transforms the data into pages and returns helper functions to paginate through them. The `range` filter is automatically applied based on the `pageSize` parameter. The respective configuration parameter can be passed as second argument. -`nextPage()` and `previousPage()` are `undefined` if there is no next or previous page respectively. `setPage` allows you to jump to a page. +`nextPage()` and `previousPage()` are `undefined` if there is no next or previous page respectively. The boolean `hasNextPage` and `hasPreviousPage` values expose the same pagination availability without overloading the helper functions themselves. `setPage` allows you to jump to a page. The hook does not use a count query and therefore does not know how many pages there are in total. Instead, it queries one item more than the `pageSize` to know whether there is another page after the current one. @@ -109,6 +109,8 @@ The hook does not use a count query and therefore does not know how many pages t setPage, pages, pageIndex, + hasNextPage, + hasPreviousPage, isValidating, error, } = useInfiniteOffsetPaginationQuery( @@ -132,7 +134,7 @@ The hook does not use a count query and therefore does not know how many pages t Wrapper around the infinite hooks that transforms the data into a flat list and returns a `loadMore` function. The `range` filter is automatically applied based on the `pageSize` parameter. The `SWRConfigurationInfinite` can be passed as second argument. -`loadMore()` is `undefined` if there is no more data to load. +`loadMore()` is `undefined` if there is no more data to load, or while a newly requested page has not loaded yet. It remains available for already-loaded stale data while SWR revalidates. The hook does not use a count query and therefore does not know how many items there are in total. Instead, it queries one item more than the `pageSize` to know whether there is more data to load. @@ -179,9 +181,9 @@ For the cursor pagination to work, the query _has to have_: - all ordered column in the `select` clause, - and a `limit` clause that defines page size. -`loadMore()` is `undefined` if there is no more data to load. +`loadMore()` is `undefined` if there is no more data to load, or while a newly requested page has not loaded yet. It remains available for already-loaded stale data while SWR revalidates. -The hook does not use a count query and therefore does not know how many items there are in total. `loadMore` will always be truthy if the last page had a number of elements equal to the page size. +The hook does not use a count query and therefore does not know how many items there are in total. `loadMore` will be truthy if all requested pages are loaded and the last page had a number of elements equal to the page size. You need to provide `CursorSettings` to the hook: diff --git a/packages/postgrest-swr/src/query/use-cursor-infinite-scroll-query.ts b/packages/postgrest-swr/src/query/use-cursor-infinite-scroll-query.ts index 0da12d9d..60a61e06 100644 --- a/packages/postgrest-swr/src/query/use-cursor-infinite-scroll-query.ts +++ b/packages/postgrest-swr/src/query/use-cursor-infinite-scroll-query.ts @@ -229,15 +229,17 @@ function useCursorInfiniteScrollQuery< return { flatData: undefined, hasLoadMore: false }; } - let hasLoadMore = - !data || - (pageSize ? data[data.length - 1].length === Number(pageSize) : true); + const isLastRequestedPageLoaded = data && data.length >= size; + const hasLoadMore = + !!isLastRequestedPageLoaded && + data.length > 0 && + data[data.length - 1].length === Number(pageSize); return { flatData, hasLoadMore, }; - }, [data, config]); + }, [data, config, queryFactory, size]); const loadMoreFn = useCallback(() => setSize(size + 1), [size, setSize]); @@ -245,7 +247,7 @@ function useCursorInfiniteScrollQuery< data: flatData, size, setSize, - loadMore: hasLoadMore && !isValidating ? loadMoreFn : null, + loadMore: hasLoadMore ? loadMoreFn : null, isValidating, ...rest, }; diff --git a/packages/postgrest-swr/src/query/use-infinite-offset-pagination-query.ts b/packages/postgrest-swr/src/query/use-infinite-offset-pagination-query.ts index e93a11d8..5fcebd93 100644 --- a/packages/postgrest-swr/src/query/use-infinite-offset-pagination-query.ts +++ b/packages/postgrest-swr/src/query/use-infinite-offset-pagination-query.ts @@ -27,6 +27,8 @@ export type SWRInfiniteOffsetPaginationPostgrestResponse = Omit< pages: SWRInfiniteResponse['data']; currentPage: null | Result[]; pageIndex: number; + hasNextPage: boolean; + hasPreviousPage: boolean; setPage: (idx: number) => void; nextPage: null | (() => void); previousPage: null | (() => void); @@ -150,6 +152,11 @@ function useInfiniteOffsetPaginationQuery< const parsedData = (data ?? []).map((p) => p.data); const hasMore = Array.isArray(data) && data.length > 0 && data[data.length - 1].hasMore; + const isCurrentPageLoaded = parsedData.length > currentPageIndex; + const hasNextPage = + isCurrentPageLoaded && (hasMore || currentPageIndex < size - 1); + const hasPreviousPage = + currentPageIndex > 0 && parsedData.length >= currentPageIndex; const setPage = useCallback( (idx: number) => { @@ -177,12 +184,11 @@ function useInfiniteOffsetPaginationQuery< pages: parsedData, currentPage: parsedData ? (parsedData[currentPageIndex] ?? []) : [], pageIndex: currentPageIndex, + hasNextPage, + hasPreviousPage, setPage, - nextPage: - !isValidating && (hasMore || currentPageIndex < size - 1) - ? nextPageFn - : null, - previousPage: !isValidating && currentPageIndex > 0 ? previousPageFn : null, + nextPage: hasNextPage ? nextPageFn : null, + previousPage: hasPreviousPage ? previousPageFn : null, isValidating, ...rest, }; diff --git a/packages/postgrest-swr/src/query/use-offset-infinite-scroll-query.ts b/packages/postgrest-swr/src/query/use-offset-infinite-scroll-query.ts index efc66d21..24b961ed 100644 --- a/packages/postgrest-swr/src/query/use-offset-infinite-scroll-query.ts +++ b/packages/postgrest-swr/src/query/use-offset-infinite-scroll-query.ts @@ -152,8 +152,11 @@ function useOffsetInfiniteScrollQuery< }, ); + const isLastRequestedPageLoaded = Array.isArray(data) && data.length >= size; const hasMore = - Array.isArray(data) && data.length > 0 && data[data.length - 1].hasMore; + isLastRequestedPageLoaded && + data.length > 0 && + data[data.length - 1].hasMore; const loadMoreFn = useCallback(() => setSize(size + 1), [size, setSize]); diff --git a/packages/postgrest-swr/tests/query/use-infinite-offset-pagination-query.unit.spec.tsx b/packages/postgrest-swr/tests/query/use-infinite-offset-pagination-query.unit.spec.tsx new file mode 100644 index 00000000..e2cbf0d7 --- /dev/null +++ b/packages/postgrest-swr/tests/query/use-infinite-offset-pagination-query.unit.spec.tsx @@ -0,0 +1,101 @@ +import { useInfiniteOffsetPaginationQuery } from '../../src'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import useSWRInfinite from 'swr/infinite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('swr/infinite', () => ({ + default: vi.fn(), +})); + +const mockedUseSWRInfinite = vi.mocked(useSWRInfinite); + +describe('useInfiniteOffsetPaginationQuery', () => { + beforeEach(() => { + mockedUseSWRInfinite.mockReset(); + }); + + afterEach(cleanup); + + it('keeps nextPage available for loaded stale data while revalidating', () => { + const setSize = vi.fn(); + mockedUseSWRInfinite.mockReturnValue({ + data: [{ data: [{ id: 1 }], hasMore: true }], + setSize, + size: 1, + isValidating: true, + } as any); + + function Page() { + const { hasNextPage, nextPage } = useInfiniteOffsetPaginationQuery(null); + + return ( +
+
{String(hasNextPage)}
+ {nextPage && ( +
+ ); + } + + render(); + + expect(screen.getByTestId('hasNextPage').textContent).toEqual('true'); + fireEvent.click(screen.getByTestId('nextPage')); + expect(setSize).toHaveBeenCalledOnce(); + }); + + it('does not expose nextPage again while the next page is still unloaded', () => { + mockedUseSWRInfinite.mockReturnValue({ + data: [{ data: [{ id: 1 }], hasMore: true }], + setSize: vi.fn(), + size: 1, + isValidating: true, + } as any); + + function Page() { + const { currentPage, nextPage } = useInfiniteOffsetPaginationQuery(null); + + return ( +
+
{currentPage?.length}
+ {nextPage && ( +
+ ); + } + + render(); + + fireEvent.click(screen.getByTestId('nextPage')); + + expect(screen.getByTestId('currentPageLength').textContent).toEqual('0'); + expect(screen.queryByTestId('nextPage')).toBeNull(); + }); + + it('does not expose nextPage when internal pagination has no more data', () => { + mockedUseSWRInfinite.mockReturnValue({ + data: [{ data: [{ id: 1 }], hasMore: false }], + setSize: vi.fn(), + size: 1, + isValidating: false, + } as any); + + function Page() { + const { hasNextPage, nextPage } = useInfiniteOffsetPaginationQuery(null); + + return ( +
+
{String(hasNextPage)}
+ {nextPage &&
+ ); + } + + render(); + + expect(screen.getByTestId('hasNextPage').textContent).toEqual('false'); + expect(screen.queryByTestId('nextPage')).toBeNull(); + }); +}); diff --git a/packages/postgrest-swr/tests/query/use-infinite-scroll-query.unit.spec.tsx b/packages/postgrest-swr/tests/query/use-infinite-scroll-query.unit.spec.tsx new file mode 100644 index 00000000..fd73bbff --- /dev/null +++ b/packages/postgrest-swr/tests/query/use-infinite-scroll-query.unit.spec.tsx @@ -0,0 +1,129 @@ +import { + useCursorInfiniteScrollQuery, + useOffsetInfiniteScrollQuery, +} from '../../src'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import useSWRInfinite from 'swr/infinite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('swr/infinite', () => ({ + default: vi.fn(), +})); + +const mockedUseSWRInfinite = vi.mocked(useSWRInfinite); +const cursorQueryFactory = () => + ({ + url: new URL('https://example.com?limit=1'), + }) as any; + +describe('useOffsetInfiniteScrollQuery', () => { + beforeEach(() => { + mockedUseSWRInfinite.mockReset(); + }); + + afterEach(cleanup); + + it('keeps loadMore available for loaded stale data while revalidating', () => { + const setSize = vi.fn(); + mockedUseSWRInfinite.mockReturnValue({ + data: [{ data: [{ id: 1 }], hasMore: true }], + setSize, + size: 1, + isValidating: true, + } as any); + + function Page() { + const { loadMore } = useOffsetInfiniteScrollQuery(null); + + return ( +
+ {loadMore && ( +
+ ); + } + + render(); + + fireEvent.click(screen.getByTestId('loadMore')); + expect(setSize).toHaveBeenCalledOnce(); + }); + + it('does not expose loadMore while the next page is still unloaded', () => { + mockedUseSWRInfinite.mockReturnValue({ + data: [{ data: [{ id: 1 }], hasMore: true }], + setSize: vi.fn(), + size: 2, + isValidating: true, + } as any); + + function Page() { + const { loadMore } = useOffsetInfiniteScrollQuery(null); + + return
{loadMore &&
; + } + + render(); + + expect(screen.queryByTestId('loadMore')).toBeNull(); + }); +}); + +describe('useCursorInfiniteScrollQuery', () => { + beforeEach(() => { + mockedUseSWRInfinite.mockReset(); + }); + + afterEach(cleanup); + + it('keeps loadMore available for loaded stale data while revalidating', () => { + const setSize = vi.fn(); + mockedUseSWRInfinite.mockReturnValue({ + data: [[{ id: 1 }]], + setSize, + size: 1, + isValidating: true, + } as any); + + function Page() { + const { loadMore } = useCursorInfiniteScrollQuery(cursorQueryFactory, { + orderBy: 'id', + }); + + return ( +
+ {loadMore && ( +
+ ); + } + + render(); + + fireEvent.click(screen.getByTestId('loadMore')); + expect(setSize).toHaveBeenCalledOnce(); + }); + + it('does not expose loadMore while the next page is still unloaded', () => { + mockedUseSWRInfinite.mockReturnValue({ + data: [[{ id: 1 }]], + setSize: vi.fn(), + size: 2, + isValidating: true, + } as any); + + function Page() { + const { loadMore } = useCursorInfiniteScrollQuery(cursorQueryFactory, { + orderBy: 'id', + }); + + return
{loadMore &&
; + } + + render(); + + expect(screen.queryByTestId('loadMore')).toBeNull(); + }); +});