Skip to content
Open
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/tough-rings-shout.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions docs/content/postgrest/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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(
Expand All @@ -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.

Expand Down Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,23 +229,25 @@ 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]);

return {
data: flatData,
size,
setSize,
loadMore: hasLoadMore && !isValidating ? loadMoreFn : null,
loadMore: hasLoadMore ? loadMoreFn : null,
isValidating,
...rest,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type SWRInfiniteOffsetPaginationPostgrestResponse<Result> = Omit<
pages: SWRInfiniteResponse<Result[], PostgrestError>['data'];
currentPage: null | Result[];
pageIndex: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
setPage: (idx: number) => void;
nextPage: null | (() => void);
previousPage: null | (() => void);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div data-testid="hasNextPage">{String(hasNextPage)}</div>
{nextPage && (
<button data-testid="nextPage" onClick={() => nextPage()} />
)}
</div>
);
}

render(<Page />);

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 (
<div>
<div data-testid="currentPageLength">{currentPage?.length}</div>
{nextPage && (
<button data-testid="nextPage" onClick={() => nextPage()} />
)}
</div>
);
}

render(<Page />);

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 (
<div>
<div data-testid="hasNextPage">{String(hasNextPage)}</div>
{nextPage && <button data-testid="nextPage" />}
</div>
);
}

render(<Page />);

expect(screen.getByTestId('hasNextPage').textContent).toEqual('false');
expect(screen.queryByTestId('nextPage')).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<div>
{loadMore && (
<button data-testid="loadMore" onClick={() => loadMore()} />
)}
</div>
);
}

render(<Page />);

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 <div>{loadMore && <button data-testid="loadMore" />}</div>;
}

render(<Page />);

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 (
<div>
{loadMore && (
<button data-testid="loadMore" onClick={() => loadMore()} />
)}
</div>
);
}

render(<Page />);

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 <div>{loadMore && <button data-testid="loadMore" />}</div>;
}

render(<Page />);

expect(screen.queryByTestId('loadMore')).toBeNull();
});
});
Loading