diff --git a/__tests__/atomWithInfiniteQuery_spec.tsx b/__tests__/atomWithInfiniteQuery_spec.tsx new file mode 100644 index 0000000..d7fc5ce --- /dev/null +++ b/__tests__/atomWithInfiniteQuery_spec.tsx @@ -0,0 +1,365 @@ +import React, { + Component, + StrictMode, + Suspense, + useCallback, + useEffect, +} from 'react' +import type { ReactNode } from 'react' +import { + InfiniteData, + QueryFunctionContext, + QueryKey, +} from '@tanstack/query-core' +import { fireEvent, render } from '@testing-library/react' +import { useAtom, useSetAtom } from 'jotai/react' +import { Getter, atom } from 'jotai/vanilla' +import { atomWithInfiniteQuery } from '../src/index' + +beforeEach(() => { + jest.useFakeTimers() +}) +afterEach(() => { + jest.runAllTimers() + jest.useRealTimers() +}) + +it('infinite query basic test', async () => { + let resolve = () => {} + type DataResponse = { response: { count: number } } + const countAtom = atomWithInfiniteQuery(() => ({ + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.response.count + 1, + queryKey: ['countInfinite'], + + queryFn: async ({ pageParam }) => { + await new Promise((r) => (resolve = r)) + return { response: { count: pageParam as number } } + }, + })) + + const Counter = () => { + const [countData] = useAtom(countAtom) + + const { data, isPending, isError } = countData + + if (isPending) return <>loading + if (isError) return <>error + + return ( + <> +
page count: {data.pages.length}
+ + ) + } + + const { findByText } = render( + + + + ) + + await findByText('loading') + resolve() + await findByText('page count: 1') +}) + +it('infinite query next page test', async () => { + const mockFetch = jest.fn((response) => ({ response })) + let resolve = () => {} + const countAtom = atomWithInfiniteQuery<{ response: { count: number } }>( + () => ({ + initialPageParam: 1, + queryKey: ['nextPageAtom'], + queryFn: async ({ pageParam }) => { + await new Promise((r) => (resolve = r)) + return mockFetch({ count: pageParam as number }) + }, + getNextPageParam: (lastPage) => { + const { + response: { count }, + } = lastPage + return (count + 1).toString() + }, + getPreviousPageParam: (lastPage) => { + const { + response: { count }, + } = lastPage + return (count - 1).toString() + }, + }) + ) + const Counter = () => { + const [countData] = useAtom(countAtom) + + const { isPending, isError, data, fetchNextPage, fetchPreviousPage } = + countData + + if (isPending) return <>loading + if (isError) return <>error + + return ( + <> +
page count: {data.pages.length}
+ + + + ) + } + + const { findByText, getByText } = render( + <> + + + ) + + await findByText('loading') + resolve() + await findByText('page count: 1') + expect(mockFetch).toBeCalledTimes(1) + + fireEvent.click(getByText('next')) + resolve() + await findByText('page count: 2') + expect(mockFetch).toBeCalledTimes(2) + + fireEvent.click(getByText('prev')) + resolve() + await findByText('page count: 3') + expect(mockFetch).toBeCalledTimes(3) +}) + +it('infinite query with enabled', async () => { + const slugAtom = atom(null) + + let resolve = () => {} + type DataResponse = { + response: { + slug: string + currentPage: number + } + } + const slugQueryAtom = atomWithInfiniteQuery((get) => { + const slug = get(slugAtom) + return { + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.response.currentPage + 1, + enabled: !!slug, + queryKey: ['disabled_until_value', slug], + queryFn: async ({ pageParam }) => { + await new Promise((r) => (resolve = r)) + return { + response: { slug: `hello-${slug}`, currentPage: pageParam as number }, + } + }, + } + }) + + const Slug = () => { + const [slugQueryData] = useAtom(slugQueryAtom) + const { data, isPending, isError, fetchStatus } = slugQueryData + + if (isPending && fetchStatus === 'idle') return
not enabled
+ + if (isPending) return <>loading + if (isError) return <>error + + return
slug: {data?.pages?.[0]?.response?.slug}
+ } + + const Parent = () => { + const [, setSlug] = useAtom(slugAtom) + return ( +
+ + +
+ ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('not enabled') + + fireEvent.click(getByText('set slug')) + await findByText('loading') + resolve() + await findByText('slug: hello-world') +}) + +it('infinite query with enabled 2', async () => { + jest.useRealTimers() // FIXME can avoid? + + const enabledAtom = atom(true) + const slugAtom = atom('first') + type DataResponse = { + response: { + slug: string + currentPage: number + } + } + const slugQueryAtom = atomWithInfiniteQuery((get) => { + const slug = get(slugAtom) + const isEnabled = get(enabledAtom) + return { + getNextPageParam: (lastPage) => lastPage.response.currentPage + 1, + initialPageParam: 1, + enabled: isEnabled, + queryKey: ['enabled_toggle'], + queryFn: async ({ pageParam }) => { + await new Promise((r) => setTimeout(r, 100)) // FIXME can avoid? + return { + response: { slug: `hello-${slug}`, currentPage: pageParam as number }, + } + }, + } + }) + + const Slug = () => { + const [slugQueryData] = useAtom(slugQueryAtom) + const { data, isPending, isError, fetchStatus } = slugQueryData + + if (isPending && fetchStatus === 'idle') return
not enabled
+ + if (isPending) return <>loading + if (isError) return <>error + + return
slug: {data.pages[0]?.response.slug}
+ } + + const Parent = () => { + const [, setSlug] = useAtom(slugAtom) + const [, setEnabled] = useAtom(enabledAtom) + return ( +
+ + + + +
+ ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + await findByText('slug: hello-first') + + await new Promise((r) => setTimeout(r, 100)) // FIXME we want to avoid this + fireEvent.click(getByText('set disabled')) + fireEvent.click(getByText('set slug')) + + await new Promise((r) => setTimeout(r, 100)) // FIXME we want to avoid this + await findByText('slug: hello-first') + + await new Promise((r) => setTimeout(r, 100)) // FIXME we want to avoid this + fireEvent.click(getByText('set enabled')) + await findByText('slug: hello-world') +}) + +describe('error handling', () => { + class ErrorBoundary extends Component< + { message?: string; retry?: () => void; children: ReactNode }, + { hasError: boolean } + > { + constructor(props: { message?: string; children: ReactNode }) { + super(props) + this.state = { hasError: false } + } + static getDerivedStateFromError() { + return { hasError: true } + } + render() { + return this.state.hasError ? ( +
+ {this.props.message || 'errored'} + {this.props.retry && ( + + )} +
+ ) : ( + this.props.children + ) + } + } + + it('can catch error in error boundary', async () => { + let resolve = () => {} + const countAtom = atomWithInfiniteQuery(() => ({ + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.response.count + 1, + queryKey: ['error test', 'count1Infinite'], + retry: false, + queryFn: async (): Promise<{ response: { count: number } }> => { + await new Promise((r) => (resolve = r)) + throw new Error('fetch error') + }, + throwOnError: true, + })) + const Counter = () => { + const [{ data, isPending }] = useAtom(countAtom) + + if (isPending) return <>loading + + const pages = data?.pages + + return ( + <> +
count: {pages?.[0]?.response.count}
+ + ) + } + + const { findByText } = render( + + + + + + + + ) + + await findByText('loading') + resolve() + await findByText('errored') + }) +}) diff --git a/package.json b/package.json index 8ef8427..166e2af 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "postcompile": "cp dist/index.modern.mjs dist/index.modern.js && cp dist/index.modern.mjs.map dist/index.modern.js.map", "test": "run-s eslint tsc-test jest", "eslint": "eslint --ext .js,.ts,.tsx .", - "jest": "jest __tests__/atomWithMutation_spec.tsx --watch", + "jest": "jest __tests__/atomWithInfiniteQuery_spec.tsx --watch", "tsc-test": "tsc --project . --noEmit", "examples:01_typescript": "DIR=01_typescript EXT=tsx webpack serve", "examples:02_refetch": "DIR=02_refetch EXT=tsx webpack serve", @@ -53,7 +53,7 @@ ], "license": "MIT", "devDependencies": { - "@tanstack/query-core": "^5.4.3", + "@tanstack/query-core": "^5.12.1", "@testing-library/react": "^14.0.0", "@types/jest": "^29.5.4", "@types/node": "^20.5.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcf5af0..1b2985d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: devDependencies: '@tanstack/query-core': - specifier: ^5.4.3 - version: 5.4.3 + specifier: ^5.12.1 + version: 5.12.1 '@testing-library/react': specifier: ^14.0.0 version: 14.0.0(react-dom@18.2.0)(react@18.2.0) @@ -1888,8 +1888,8 @@ packages: string.prototype.matchall: 4.0.9 dev: true - /@tanstack/query-core@5.4.3: - resolution: {integrity: sha512-fnI9ORjcuLGm1sNrKatKIosRQUpuqcD4SV7RqRSVmj8JSicX2aoMyKryHEBpVQvf6N4PaBVgBxQomjsbsGPssQ==} + /@tanstack/query-core@5.12.1: + resolution: {integrity: sha512-WbZztNmKq0t6QjdNmHzezbi/uifYo9j6e2GLJkodsYaYUlzMbAp91RDyeHkIZrm7EfO4wa6Sm5sxJZm5SPlh6w==} dev: true /@testing-library/dom@9.3.1: diff --git a/src/atomWithInfiniteQuery.ts b/src/atomWithInfiniteQuery.ts index 504aa4f..8af906c 100644 --- a/src/atomWithInfiniteQuery.ts +++ b/src/atomWithInfiniteQuery.ts @@ -1,29 +1,27 @@ import { InfiniteQueryObserver, QueryClient } from '@tanstack/query-core' import type { + DefaultError, + InfiniteData, InfiniteQueryObserverOptions, InfiniteQueryObserverResult, QueryKey, + WithRequired, } from '@tanstack/query-core' import { type Getter, atom } from 'jotai/vanilla' import { make, pipe, toObservable } from 'wonka' import { queryClientAtom } from './queryClientAtom' import { getHasError } from './utils' + export function atomWithInfiniteQuery< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData, - TQueryData = TQueryFnData, + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, >( getOptions: ( get: Getter - ) => InfiniteQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - >, + ) => InfiniteQueryOptions, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ) { const IN_RENDER = Symbol() @@ -41,8 +39,9 @@ export function atomWithInfiniteQuery< TQueryFnData, TError, TData, - TQueryData, - TQueryKey + TQueryFnData, + TQueryKey, + TPageParam > >() ) @@ -127,3 +126,24 @@ export function atomWithInfiniteQuery< return result }) } + +interface InfiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> extends WithRequired< + Omit< + InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + 'suspense' + >, + 'queryKey' + > {}