diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index a59d926..6afcb06 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,5 +1,5 @@ { "buildCommand": "compile", "sandboxes": ["new", "react-typescript-react-ts"], - "node": "14" + "node": "18" } diff --git a/.eslintignore b/.eslintignore index 8f7a94e..ec8f190 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,2 @@ /dist -/src/vendor +/src/vendor \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 43501aa..6a2ecc9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -99,9 +99,7 @@ }, "alias": { "extensions": [".js", ".jsx", ".ts", ".tsx", ".json"], - "map": [ - ["^jotai-tanstack-query$", "./src/index.ts"] - ] + "map": [["^jotai-tanstack-query$", "./src/index.ts"]] } } }, diff --git a/__tests__/01_basic_spec.tsx b/__tests__/01_basic_spec.tsx index 19b034a..01d452f 100644 --- a/__tests__/01_basic_spec.tsx +++ b/__tests__/01_basic_spec.tsx @@ -1,17 +1,21 @@ import { - atomsWithInfiniteQuery, - atomsWithMutation, - atomsWithQuery, - atomsWithQueryAsync, + atomWithInfiniteQuery, + atomWithMutation, + atomWithMutationState, + atomWithQuery, + atomWithSuspenseInfiniteQuery, + atomWithSuspenseQuery, queryClientAtom, } from '../src/index' describe('basic spec', () => { it('should export functions', () => { expect(queryClientAtom).toBeDefined() - expect(atomsWithQuery).toBeDefined() - expect(atomsWithInfiniteQuery).toBeDefined() - expect(atomsWithMutation).toBeDefined() - expect(atomsWithQueryAsync).toBeDefined() + expect(atomWithQuery).toBeDefined() + expect(atomWithInfiniteQuery).toBeDefined() + expect(atomWithMutation).toBeDefined() + expect(atomWithSuspenseQuery).toBeDefined() + expect(atomWithSuspenseInfiniteQuery).toBeDefined() + expect(atomWithMutationState).toBeDefined() }) }) diff --git a/__tests__/atomWithInfiniteQuery_spec.tsx b/__tests__/atomWithInfiniteQuery_spec.tsx new file mode 100644 index 0000000..975ba19 --- /dev/null +++ b/__tests__/atomWithInfiniteQuery_spec.tsx @@ -0,0 +1,353 @@ +import React, { Component, StrictMode, Suspense } from 'react' +import type { ReactNode } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import { atomWithInfiniteQuery } from '../src/index' + +let originalConsoleError: typeof console.error + +beforeEach(() => { + originalConsoleError = console.error + console.error = jest.fn() +}) +afterEach(() => { + console.error = originalConsoleError +}) + +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 () => { + const enabledAtom = atom(true) + const slugAtom = atom('first') + type DataResponse = { + response: { + slug: string + currentPage: number + } + } + let resolve = () => {} + 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) => (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) + const [, setEnabled] = useAtom(enabledAtom) + return ( +
+ + + + +
+ ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('slug: hello-first') + fireEvent.click(getByText('set disabled')) + fireEvent.click(getByText('set slug')) + + await findByText('slug: hello-first') + + fireEvent.click(getByText('set enabled')) + resolve() + 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/__tests__/atomWithMutationState_spec.tsx b/__tests__/atomWithMutationState_spec.tsx new file mode 100644 index 0000000..5dc2f23 --- /dev/null +++ b/__tests__/atomWithMutationState_spec.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { QueryClient } from '@tanstack/query-core' +import { fireEvent, render } from '@testing-library/react' +import { Provider, useAtom } from 'jotai' +import { atomWithMutation, atomWithMutationState } from '../src' + +it('atomWithMutationState multiple', async () => { + const client = new QueryClient() + let resolve1: (() => void) | undefined + const mutateAtom1 = atomWithMutation( + () => ({ + mutationKey: ['test-atom'], + mutationFn: async (a) => { + await new Promise((r) => { + resolve1 = r + }) + return a + }, + }), + () => client + ) + let resolve2: (() => void) | undefined + const mutateAtom2 = atomWithMutation( + () => ({ + mutationKey: ['test-atom'], + mutationFn: async (a) => { + await new Promise((r) => { + resolve2 = r + }) + return a + }, + }), + () => client + ) + + const mutationStateAtom = atomWithMutationState( + () => ({ filters: { mutationKey: ['test-atom'] } }), + () => client + ) + + function App() { + const [{ mutate: mutate1 }] = useAtom(mutateAtom1) + const [{ mutate: mutate2 }] = useAtom(mutateAtom2) + const [mutations] = useAtom(mutationStateAtom) + + return ( +
+

mutationCount: {mutations.length}

+ +
+ ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('mutationCount: 0') + fireEvent.click(getByText('mutate')) + await findByText('mutationCount: 2') + resolve1?.() + await findByText('mutationCount: 1') + resolve2?.() + await findByText('mutationCount: 0') +}) diff --git a/__tests__/atomWithMutation.tsx b/__tests__/atomWithMutation_spec.tsx similarity index 57% rename from __tests__/atomWithMutation.tsx rename to __tests__/atomWithMutation_spec.tsx index 15b8e7d..bc0bc31 100644 --- a/__tests__/atomWithMutation.tsx +++ b/__tests__/atomWithMutation_spec.tsx @@ -1,21 +1,19 @@ import React, { useState } from 'react' import { fireEvent, render } from '@testing-library/react' import { useAtom } from 'jotai/react' -import { atomsWithMutation } from '../src/index' +import { atomWithMutation } from '../src/index' -it('atomsWithMutation should be refreshed on unmount (#2060)', async () => { +it('atomWithMutation should be refreshed on unmount (#2060)', async () => { let resolve: (() => void) | undefined - const [, testAtom] = atomsWithMutation( - () => ({ - mutationKey: ['test-atom'], - mutationFn: async (a) => { - await new Promise((r) => { - resolve = r - }) - return a - }, - }) - ) + const mutateAtom = atomWithMutation(() => ({ + mutationKey: ['test-atom'], + mutationFn: async (a) => { + await new Promise((r) => { + resolve = r + }) + return a + }, + })) function App() { const [mount, setMount] = useState(true) @@ -29,11 +27,11 @@ it('atomsWithMutation should be refreshed on unmount (#2060)', async () => { } function TestView() { - const [state, mutate] = useAtom(testAtom) + const [{ mutate, isPending, status }] = useAtom(mutateAtom) return (
-

status: {state.status}

-
@@ -45,12 +43,10 @@ it('atomsWithMutation should be refreshed on unmount (#2060)', async () => { await findByText('status: idle') fireEvent.click(getByText('mutate')) - await findByText('status: loading') + await findByText('status: pending') resolve?.() await findByText('status: success') - fireEvent.click(getByText('mutate')) - await findByText('status: loading') fireEvent.click(getByText('unmount')) fireEvent.click(getByText('mount')) await findByText('status: idle') diff --git a/__tests__/atomsWithQuery_spec.tsx b/__tests__/atomWithQuery_spec.tsx similarity index 53% rename from __tests__/atomsWithQuery_spec.tsx rename to __tests__/atomWithQuery_spec.tsx index 82ba89b..925d864 100644 --- a/__tests__/atomsWithQuery_spec.tsx +++ b/__tests__/atomWithQuery_spec.tsx @@ -1,81 +1,55 @@ -import React, { Component, StrictMode, Suspense, useState } from 'react' -import type { ReactNode } from 'react' +import React, { StrictMode, Suspense, useState } from 'react' import { QueryClient } from '@tanstack/query-core' import { fireEvent, render } from '@testing-library/react' -import { useAtom, useSetAtom } from 'jotai/react' -import { atom } from 'jotai/vanilla' -import { atomsWithQuery } from '../src/index' +import { Getter, atom, useAtom, useSetAtom } from 'jotai' +import { unwrap } from 'jotai/utils' +import { ErrorBoundary } from 'react-error-boundary' +import { atomWithQuery } from '../src' + +let originalConsoleError: typeof console.error beforeEach(() => { jest.useFakeTimers() + originalConsoleError = console.error + console.error = jest.fn() }) afterEach(() => { jest.runAllTimers() jest.useRealTimers() + console.error = originalConsoleError }) it('query basic test', async () => { let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['count1'], + const countAtom = atomWithQuery(() => ({ + queryKey: ['test1'], queryFn: async () => { await new Promise((r) => (resolve = r)) return { response: { count: 0 } } }, })) const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) - return ( - <> -
count: {count}
- - ) - } + const [countData] = useAtom(countAtom) + const { data, isPending, isError } = countData - const { findByText } = render( - - - - - - ) + if (isPending) { + return <>loading + } - await findByText('loading') - resolve() - await findByText('count: 0') -}) + if (isError) { + return <>errorred + } -it('query basic test with object instead of function', async () => { - let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['count2'], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - return { response: { count: 0 } } - }, - })) - const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) return ( <> -
count: {count}
+
count: {data.response.count}
) } const { findByText } = render( - - - + ) @@ -84,124 +58,65 @@ it('query basic test with object instead of function', async () => { await findByText('count: 0') }) -it('query refetch', async () => { - let count = 0 - const mockFetch = jest.fn((response) => ({ response })) - let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['count3'], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - const response = mockFetch({ count }) - ++count - return response - }, - })) - const Counter = () => { - const [ - { - response: { count }, - }, - dispatch, - ] = useAtom(countAtom) - return ( - <> -
count: {count}
- - - ) - } - - const { findByText, getByText } = render( - - - - - - ) +it('async query basic test', async () => { + const fn = jest.fn(() => Promise.resolve(2)) + const queryFn = jest.fn((id) => { + return Promise.resolve({ response: { id } }) + }) - await findByText('loading') - resolve() - await findByText('count: 0') - expect(mockFetch).toBeCalledTimes(1) + const userIdAtom = atom(async () => { + return await fn() + }) - fireEvent.click(getByText('refetch')) - await findByText('loading') - resolve() - await findByText('count: 1') - expect(mockFetch).toBeCalledTimes(2) -}) + const userAtom = atomWithQuery((get) => { + const userId = get(unwrap(userIdAtom)) -it('query loading', async () => { - let count = 0 - const mockFetch = jest.fn((response) => ({ response })) - let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['count4'], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - const response = mockFetch({ count }) - ++count - return response - }, - })) - const derivedAtom = atom((get) => get(countAtom)) - const dispatchAtom = atom(null, (_get, set, action: any) => { - set(countAtom, action) - }) - const Counter = () => { - const [ - { - response: { count }, + return { + queryKey: ['userId', userId], + queryFn: async ({ queryKey: [, id] }) => { + const res = await queryFn(id) + return res }, - ] = useAtom(derivedAtom) + enabled: !!userId, + } + }) + const User = () => { + const [userData] = useAtom(userAtom) + const { data, isPending, isError } = userData + + if (isPending) return <>loading + if (isError) return <>errorred + return ( <> -
count: {count}
+
id: {data.response.id}
) } - const RefreshButton = () => { - const [, dispatch] = useAtom(dispatchAtom) - return ( - - ) - } - - const { findByText, getByText } = render( + const { findByText } = render( - + - ) await findByText('loading') - resolve() - await findByText('count: 0') - - fireEvent.click(getByText('refetch')) - await findByText('loading') - resolve() - await findByText('count: 1') - - fireEvent.click(getByText('refetch')) - await findByText('loading') - resolve() - await findByText('count: 2') + await findByText('id: 2') + expect(queryFn).toHaveBeenCalledTimes(1) }) -it('query loading 2', async () => { +it('query refetch', async () => { let count = 0 - const mockFetch = jest.fn((response) => ({ response })) + const mockFetch = jest.fn< + { response: { count: number } }, + { count: number }[] + >((response) => ({ + response, + })) let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['count5'], + const countAtom = atomWithQuery(() => ({ + queryKey: ['test3'], queryFn: async () => { await new Promise((r) => (resolve = r)) const response = mockFetch({ count }) @@ -209,105 +124,50 @@ it('query loading 2', async () => { return response }, })) - const Counter = () => { - const [ - { - response: { count }, - }, - dispatch, - ] = useAtom(countAtom) - return ( - <> -
count: {count}
- - - ) - } - const { findByText, getByText } = render( - - - - - - ) + const [{ data, isPending, isError, refetch }] = useAtom(countAtom) - await findByText('loading') - resolve() - await findByText('count: 0') - - fireEvent.click(getByText('refetch')) - await findByText('loading') - resolve() - await findByText('count: 1') + if (isPending) { + return <>loading + } - fireEvent.click(getByText('refetch')) - await findByText('loading') - resolve() - await findByText('count: 2') -}) + if (isError) { + return <>errorred + } -it('query no-loading with keepPreviousData', async () => { - const dataAtom = atom(0) - const mockFetch = jest.fn((response) => ({ response })) - let resolve = () => {} - const [countAtom] = atomsWithQuery((get) => ({ - queryKey: ['keepPreviousData', get(dataAtom)], - keepPreviousData: true, - queryFn: async () => { - await new Promise((r) => (resolve = r)) - const response = mockFetch({ count: get(dataAtom) }) - return response - }, - })) - const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) return ( <> -
count: {count}
+
count: {data?.response.count}
+ ) } - const RefreshButton = () => { - const [data, setData] = useAtom(dataAtom) - return - } const { findByText, getByText } = render( - - - - + ) await findByText('loading') resolve() await findByText('count: 0') + expect(mockFetch).toBeCalledTimes(1) fireEvent.click(getByText('refetch')) - await expect(() => findByText('loading')).rejects.toThrow() + await expect(() => findByText('loading')).rejects.toThrow() //refetch implementation in tanstack doesn't trigger loading state resolve() await findByText('count: 1') - - fireEvent.click(getByText('refetch')) - await expect(() => findByText('loading')).rejects.toThrow() - resolve() - await findByText('count: 2') + expect(mockFetch).toBeCalledTimes(2) }) it('query with enabled', async () => { const slugAtom = atom(null) - const mockFetch = jest.fn((response) => ({ response })) + const mockFetch = jest.fn<{ response: { slug: string } }, { slug: string }[]>( + (response) => ({ response }) + ) let resolve = () => {} - const [, slugQueryAtom] = atomsWithQuery((get) => { + const slugQueryAtom = atomWithQuery((get) => { const slug = get(slugAtom) return { enabled: !!slug, @@ -320,9 +180,24 @@ it('query with enabled', async () => { }) const Slug = () => { - const [{ data }] = useAtom(slugQueryAtom) - if (!data?.response?.slug) return
not enabled
- return
slug: {data?.response?.slug}
+ const [slugQueryData] = useAtom(slugQueryAtom) + + const { data, isPending, isError, status, fetchStatus } = slugQueryData + + //ref: https://tanstack.com/query/v4/docs/react/guides/dependent-queries + if (status === 'pending' && fetchStatus === 'idle') { + return
not enabled
+ } + + if (isPending) { + return <>loading + } + + if (isError) { + return <>errorred + } + + return
slug: {data.response.slug}
} const Parent = () => { @@ -342,9 +217,7 @@ it('query with enabled', async () => { const { getByText, findByText } = render( - - - + ) @@ -352,34 +225,49 @@ it('query with enabled', async () => { expect(mockFetch).toHaveBeenCalledTimes(0) fireEvent.click(getByText('set slug')) - // await findByText('loading') + await findByText('loading') resolve() await findByText('slug: hello-world') expect(mockFetch).toHaveBeenCalledTimes(1) }) it('query with enabled 2', async () => { - const mockFetch = jest.fn((response) => ({ response })) + const mockFetch = jest.fn<{ response: { slug: string } }, { slug: string }[]>( + (response) => ({ response }) + ) const enabledAtom = atom(true) const slugAtom = atom('first') - const [, slugQueryAtom] = atomsWithQuery((get) => { + const slugQueryAtom = atomWithQuery((get: Getter) => { const slug = get(slugAtom) - const isEnabled = get(enabledAtom) + const enabled = get(enabledAtom) return { - enabled: isEnabled, + enabled: enabled, queryKey: ['enabled_toggle'], queryFn: async () => { await new Promise((r) => setTimeout(r, 10 * 1000)) + return mockFetch({ slug: `hello-${slug}` }) }, } }) const Slug = () => { - const [{ data }] = useAtom(slugQueryAtom) - if (!data?.response?.slug) return
not enabled
- return
slug: {data?.response?.slug}
+ const [slugQueryAtomData] = useAtom(slugQueryAtom) + const { data, isError, isPending, status, fetchStatus } = slugQueryAtomData + + if (status === 'pending' && fetchStatus === 'idle') { + return
not enabled
+ } + + if (isPending) { + return <>loading + } + + if (isError) { + return <>errorred + } + return
slug: {data.response.slug}
} const Parent = () => { @@ -412,9 +300,7 @@ it('query with enabled 2', async () => { const { getByText, findByText } = render( - - - + ) @@ -437,7 +323,7 @@ it('query with enabled 2', async () => { it('query with enabled (#500)', async () => { const enabledAtom = atom(true) let resolve = () => {} - const [countAtom] = atomsWithQuery((get) => { + const countAtom = atomWithQuery((get) => { const enabled = get(enabledAtom) return { enabled, @@ -450,12 +336,19 @@ it('query with enabled (#500)', async () => { }) const Counter = () => { - const [value] = useAtom(countAtom) - if (!value) return null - const { - response: { count }, - } = value - return
count: {count}
+ const [countData] = useAtom(countAtom) + + const { data, isPending, isError } = countData + + if (isPending) { + return <>loading + } + + if (isError) { + return <>errorred + } + + return
count: {data.response.count}
} const Parent = () => { @@ -498,20 +391,29 @@ it('query with enabled (#500)', async () => { it('query with initialData test', async () => { const mockFetch = jest.fn((response) => ({ response })) + let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ + const countAtom = atomWithQuery(() => ({ queryKey: ['initialData_count1'], queryFn: async () => { + await new Promise((r) => (resolve = r)) return mockFetch({ count: 10 }) }, initialData: { response: { count: 0 } }, })) const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) + const [countData] = useAtom(countAtom) + const { data, isPending, isError } = countData + + if (isPending) { + return <>loading + } + + if (isError) { + return <>errorred + } + + const count = data.response.count return ( <>
count: {count}
@@ -525,8 +427,10 @@ it('query with initialData test', async () => { ) - // NOTE: the atom never suspends + // NOTE: the atom is never loading + await expect(() => findByText('loading')).rejects.toThrow() await findByText('count: 0') + resolve() await findByText('count: 10') expect(mockFetch).toHaveBeenCalledTimes(1) }) @@ -537,7 +441,7 @@ it('query dependency test', async () => { set(baseCountAtom, (c) => c + 1) ) let resolve = () => {} - const [countAtom] = atomsWithQuery((get) => ({ + const countAtom = atomWithQuery((get) => ({ queryKey: ['count_with_dependency', get(baseCountAtom)], queryFn: async () => { await new Promise((r) => (resolve = r)) @@ -546,14 +450,20 @@ it('query dependency test', async () => { })) const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) + const [countData] = useAtom(countAtom) + const { data, isPending, isError } = countData + + if (isPending) { + return <>loading + } + + if (isError) { + return <>errorred + } + return ( <> -
count: {count}
+
count: {data.response.count}
) } @@ -565,9 +475,7 @@ it('query dependency test', async () => { const { getByText, findByText } = render( - - - + ) @@ -582,67 +490,81 @@ it('query dependency test', async () => { await findByText('count: 1') }) -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 } +it('query expected QueryCache test', async () => { + const queryClient = new QueryClient() + let resolve = () => {} + const countAtom = atomWithQuery( + () => ({ + queryKey: ['count6'], + queryFn: async () => { + await new Promise((r) => (resolve = r)) + return { response: { count: 0 } } + }, + }), + () => queryClient + ) + const Counter = () => { + const [countData] = useAtom(countAtom) + + const { data, isPending, isError } = countData + + if (isPending) { + return <>loading } - render() { - return this.state.hasError ? ( -
- {this.props.message || 'errored'} - {this.props.retry && ( - - )} -
- ) : ( - this.props.children - ) + + if (isError) { + return <>errorred } + + return ( + <> +
count: {data.response.count}
+ + ) } + const { findByText } = render( + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 0') + expect(queryClient.getQueryCache().getAll().length).toBe(1) +}) + +describe('error handling', () => { it('can catch error in error boundary', async () => { let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['error test', 'count1'], + const countAtom = atomWithQuery(() => ({ + queryKey: ['catch'], retry: false, queryFn: async (): Promise<{ response: { count: number } }> => { await new Promise((r) => (resolve = r)) throw new Error('fetch error') }, + throwOnError: true, })) const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) - return ( - <> -
count: {count}
- - ) + const [countData] = useAtom(countAtom) + + if ('data' in countData) { + if (countData.isPending) { + return <>loading + } + + return
count: {countData.data?.response.count}
+ } + + return null } const { findByText } = render( - - - - + errored}> + ) @@ -656,7 +578,7 @@ describe('error handling', () => { let count = -1 let willThrowError = false let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ + const countAtom = atomWithQuery(() => ({ queryKey: ['error test', 'count2'], retry: false, queryFn: async () => { @@ -668,33 +590,31 @@ describe('error handling', () => { } return { response: { count } } }, + throwOnError: true, })) const Counter = () => { - const [ - { - response: { count }, - }, - dispatch, - ] = useAtom(countAtom) - const refetch = () => dispatch({ type: 'refetch', force: true }) + const [countData] = useAtom(countAtom) + if (countData.isFetching) return <>loading return ( <> -
count: {count}
- +
count: {countData.data?.response.count}
+ ) } const App = () => { - const dispatch = useSetAtom(countAtom) - const retry = () => { - dispatch({ type: 'refetch', force: true }) - } return ( - - - - + { + return ( + <> +

errored

+ + + ) + }}> +
) } @@ -726,62 +646,22 @@ describe('error handling', () => { }) }) -it('query expected QueryCache test', async () => { - const queryClient = new QueryClient() - let resolve = () => {} - const [countAtom] = atomsWithQuery( - () => ({ - queryKey: ['count6'], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - return { response: { count: 0 } } - }, - }), - () => queryClient - ) - const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) - - return ( - <> -
count: {count}
- - ) - } - - const { findByText } = render( - - - - - - ) - - await findByText('loading') - resolve() - await findByText('count: 0') - expect(queryClient.getQueryCache().getAll().length).toBe(1) -}) - -// Test for bug described here: -// https://github.com/jotaijs/jotai-tanstack-query/issues/34 +// // Test for bug described here: +// // https://github.com/jotaijs/jotai-tanstack-query/issues/34 +// Note: If error handling tests run after this test, they are failing. Not sure why. it('renews the result when the query changes and a non stale cache is available', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000 } }, }) - queryClient.setQueryData([2], 2) + queryClient.setQueryData(['currentCount', 2], 2) const currentCountAtom = atom(1) - const [, countAtom] = atomsWithQuery( + const countAtom = atomWithQuery( (get) => { const currentCount = get(currentCountAtom) return { - queryKey: [currentCount], + queryKey: ['currentCount', currentCount], queryFn: () => currentCount, } }, @@ -790,11 +670,22 @@ it('renews the result when the query changes and a non stale cache is available' const Counter = () => { const setCurrentCount = useSetAtom(currentCountAtom) - const [{ data: count }] = useAtom(countAtom) + const [countData] = useAtom(countAtom) + + const { data, isPending, isError } = countData + + if (isPending) { + return <>loading + } + + if (isError) { + return <>errorred + } + return ( <> -
count: {count}
+
count: {data}
) } @@ -804,8 +695,9 @@ it('renews the result when the query changes and a non stale cache is available' ) - + await findByText('loading') await findByText('count: 1') fireEvent.click(await findByText('Set count to 2')) + await expect(() => findByText('loading')).rejects.toThrow() await findByText('count: 2') }) diff --git a/__tests__/atomWithSuspenseInfiniteQuery_spec.tsx b/__tests__/atomWithSuspenseInfiniteQuery_spec.tsx new file mode 100644 index 0000000..81e0625 --- /dev/null +++ b/__tests__/atomWithSuspenseInfiniteQuery_spec.tsx @@ -0,0 +1,57 @@ +import React, { StrictMode, Suspense } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtom } from 'jotai' +import { atomWithSuspenseInfiniteQuery } from '../src' + +it('suspense basic, suspends', async () => { + let resolve = () => {} + type DataResponse = { + response: { + count: number + } + } + const countAtom = atomWithSuspenseInfiniteQuery(() => ({ + getNextPageParam: (lastPage) => { + const nextPageParam = lastPage.response.count + 1 + return nextPageParam + }, + initialPageParam: 1, + queryKey: ['test1'], + queryFn: async ({ pageParam }) => { + await new Promise((r) => (resolve = r)) + return { response: { count: pageParam as number } } + }, + })) + const Counter = () => { + const [countData] = useAtom(countAtom) + const { data, fetchNextPage } = countData + return ( + <> +
count: {data?.pages?.[data.pages.length - 1]?.response.count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 1') + + fireEvent.click(getByText('fetchNextPage')) + await expect(() => findByText('loading')).rejects.toThrow() //refetch implementation in tanstack doesn't trigger loading state + resolve() + await findByText('count: 2') +}) diff --git a/__tests__/atomWithSuspenseQuery_spec.tsx b/__tests__/atomWithSuspenseQuery_spec.tsx new file mode 100644 index 0000000..d9b1449 --- /dev/null +++ b/__tests__/atomWithSuspenseQuery_spec.tsx @@ -0,0 +1,461 @@ +import React, { StrictMode, Suspense } from 'react' +import { QueryClient } from '@tanstack/query-core' +import { fireEvent, render } from '@testing-library/react' +import { atom, useAtom, useSetAtom } from 'jotai' +import { ErrorBoundary } from 'react-error-boundary' +import { atomWithSuspenseQuery } from '../src' + +let originalConsoleError: typeof console.error + +beforeEach(() => { + originalConsoleError = console.error + console.error = jest.fn() +}) +afterEach(() => { + console.error = originalConsoleError +}) + +it('suspense basic, suspends', async () => { + let resolve = () => {} + const countAtom = atomWithSuspenseQuery(() => ({ + queryKey: ['test1'], + queryFn: async () => { + await new Promise((r) => (resolve = r)) + return { response: { count: 0 } } + }, + })) + const Counter = () => { + const [{ data }] = useAtom(countAtom) + return ( + <> +
count: {data.response.count}
+ + ) + } + + const { findByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 0') +}) + +it('query refetch', async () => { + const mockFetch = jest.fn< + { response: { message: string } }, + { message: string }[] + >((response) => ({ + response, + })) + let resolve = () => {} + const greetingAtom = atomWithSuspenseQuery(() => ({ + queryKey: ['test3'], + queryFn: async () => { + await new Promise((r) => (resolve = r)) + const response = mockFetch({ message: 'helloWorld' }) + return response + }, + })) + const Greeting = () => { + const [{ data, refetch }] = useAtom(greetingAtom) + + return ( + <> +
message: {data?.response.message}
+ + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('message: helloWorld') + expect(mockFetch).toBeCalledTimes(1) + + fireEvent.click(getByText('refetch')) + await expect(() => findByText('loading')).rejects.toThrow() //refetch implementation in tanstack doesn't trigger loading state + resolve() + await findByText('message: helloWorld') + expect(mockFetch).toBeCalledTimes(2) //this ensures we are actually running the query function again +}) + +describe('intialData test', () => { + it('query with initialData test', async () => { + const mockFetch = jest.fn((response) => ({ response })) + let resolve = () => {} + + const countAtom = atomWithSuspenseQuery(() => ({ + queryKey: ['initialData_count1'], + queryFn: async () => { + await new Promise((r) => (resolve = r)) + return mockFetch({ count: 10 }) + }, + initialData: { response: { count: 0 } }, + staleTime: 0, + })) + const Counter = () => { + const [countData] = useAtom(countAtom) + const { data, isError } = countData + + if (isError) { + return <>errorred + } + + const count = data?.response.count + return ( + <> +
count: {count}
+ + ) + } + + const { findByText } = render( + + + + + + ) + + // NOTE: the atom is never loading + await expect(() => findByText('loading')).rejects.toThrow() + await findByText('count: 0') + resolve() + await findByText('count: 10') + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('query with initialData test with dependency', async () => { + const mockFetch = jest.fn((response) => ({ response })) + let resolve = () => {} + const numberAtom = atom(10) + const countAtom = atomWithSuspenseQuery((get) => ({ + queryKey: ['initialData_count1', get(numberAtom)], + queryFn: async ({ queryKey: [, myNumber] }) => { + await new Promise((r) => (resolve = r)) + return mockFetch({ count: myNumber }) + }, + initialData: { response: { count: 0 } }, + staleTime: 0, + })) + const Counter = () => { + const [countData] = useAtom(countAtom) + const { data, isError } = countData + if (isError) { + return <>errorred + } + const count = data?.response.count + return ( + <> +
count: {count}
+ + ) + } + + const Increment = () => { + const setNumber = useSetAtom(numberAtom) + return + } + const { findByText } = render( + + + + + + + ) + // NOTE: the atom is never loading + await expect(() => findByText('loading')).rejects.toThrow() + await findByText('count: 0') + resolve() + await findByText('count: 10') + expect(mockFetch).toHaveBeenCalledTimes(1) + await findByText('increment') + fireEvent.click(await findByText('increment')) + await findByText('count: 0') + resolve() + await findByText('count: 11') + expect(mockFetch).toHaveBeenCalledTimes(2) + }) +}) + +it('query dependency test', async () => { + const baseCountAtom = atom(0) + const incrementAtom = atom(null, (_get, set) => + set(baseCountAtom, (c) => c + 1) + ) + let resolve = () => {} + const countAtom = atomWithSuspenseQuery((get) => ({ + queryKey: ['count_with_dependency', get(baseCountAtom)], + queryFn: async () => { + await new Promise((r) => (resolve = r)) + return { response: { count: get(baseCountAtom) } } + }, + })) + + const Counter = () => { + const [{ data }] = useAtom(countAtom) + + return ( + <> +
count: {data.response.count}
+ + ) + } + + const Controls = () => { + const [, increment] = useAtom(incrementAtom) + return + } + + const { getByText, findByText } = render( + + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 0') + + fireEvent.click(getByText('increment')) + await findByText('loading') + resolve() + await findByText('count: 1') +}) + +it('query expected QueryCache test', async () => { + const queryClient = new QueryClient() + let resolve = () => {} + const countAtom = atomWithSuspenseQuery( + () => ({ + queryKey: ['count6'], + queryFn: async () => { + await new Promise((r) => (resolve = r)) + return { response: { count: 0 } } + }, + }), + () => queryClient + ) + const Counter = () => { + const [{ data }] = useAtom(countAtom) + + return ( + <> +
count: {data.response.count}
+ + ) + } + + const { findByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 0') + expect(queryClient.getQueryCache().getAll().length).toBe(1) +}) + +describe('error handling', () => { + it('can catch error in error boundary', async () => { + let resolve = () => {} + const countAtom = atomWithSuspenseQuery(() => ({ + queryKey: ['catch'], + retry: false, + queryFn: async (): Promise<{ response: { count: number } }> => { + await new Promise((r) => (resolve = r)) + throw new Error('fetch error') + }, + })) + const Counter = () => { + const [{ data }] = useAtom(countAtom) + return
count: {data.response.count}
+ } + const { findByText } = render( + + errored}> + + + + + + ) + await findByText('loading') + resolve() + await findByText('errored') + }) + // it('can recover from error', async () => { + // let count = -1 + // let willThrowError = false + // let resolve = () => {} + // const countAtom = atomWithSuspenseQuery(() => ({ + // queryKey: ['error test', 'count2'], + // retry: false, + // queryFn: async () => { + // willThrowError = !willThrowError + // ++count + // await new Promise((r) => (resolve = r)) + // if (willThrowError) { + // throw new Error('fetch error') + // } + // return { response: { count } } + // }, + // })) + // const Counter = () => { + // const [countData] = useAtom(countAtom) + // return ( + // <> + //
count: {countData.data?.response.count}
+ // + // + // ) + // } + // const App = () => { + // return ( + // <> + // { + // return ( + // <> + //

errored

+ // + // + // ) + // }}> + // + // + // + //
+ // + // ) + // } + // const { findByText, getByText } = render() + // await findByText('loading') + // resolve() + // await findByText('errored') + // fireEvent.click(getByText('retry')) + // await findByText('loading') + // resolve() + // await findByText('count: 1') + // fireEvent.click(getByText('refetch')) + // await findByText('loading') + // resolve() + // await findByText('errored') + // fireEvent.click(getByText('retry')) + // await findByText('loading') + // resolve() + // await findByText('count: 3') + // }) +}) + +it('renews the result when the query changes and a non stale cache is available', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 5 * 60 * 1000 } }, + }) + queryClient.setQueryData([2], 2) + + const currentCountAtom = atom(1) + + const countAtom = atomWithSuspenseQuery( + (get) => { + const currentCount = get(currentCountAtom) + return { + queryKey: [currentCount], + queryFn: () => currentCount, + } + }, + () => queryClient + ) + + const Counter = () => { + const setCurrentCount = useSetAtom(currentCountAtom) + const [{ data }] = useAtom(countAtom) + + return ( + <> + +
count: {data}
+ + ) + } + + const { findByText } = render( + + + + + + ) + await findByText('loading') + await findByText('count: 1') + fireEvent.click(await findByText('Set count to 2')) + await expect(() => findByText('loading')).rejects.toThrow() + await findByText('count: 2') +}) + +it('on reset, throws suspense', async () => { + const queryClient = new QueryClient() + let count = 0 + let resolve = () => {} + const countAtom = atomWithSuspenseQuery( + () => ({ + queryKey: ['test1', count], + queryFn: async () => { + await new Promise((r) => (resolve = r)) + count++ + return { response: { count } } + }, + }), + () => queryClient + ) + const Counter = () => { + const [{ data }] = useAtom(countAtom) + return ( + <> +
count: {data.response.count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 1') + fireEvent.click(getByText('reset')) + await findByText('loading') + resolve() + await findByText('count: 2') +}) diff --git a/__tests__/atomsWithInfiniteQuery_spec.tsx b/__tests__/atomsWithInfiniteQuery_spec.tsx deleted file mode 100644 index 0e10423..0000000 --- a/__tests__/atomsWithInfiniteQuery_spec.tsx +++ /dev/null @@ -1,459 +0,0 @@ -import React, { Component, StrictMode, Suspense, useCallback } from 'react' -import type { ReactNode } from 'react' -import { fireEvent, render } from '@testing-library/react' -import { useAtom, useSetAtom } from 'jotai/react' -import { atom } from 'jotai/vanilla' -import { atomsWithInfiniteQuery } from '../src/index' - -beforeEach(() => { - jest.useFakeTimers() -}) -afterEach(() => { - jest.runAllTimers() - jest.useRealTimers() -}) - -it('infinite query basic test', async () => { - let resolve = () => {} - const [countAtom] = atomsWithInfiniteQuery< - { response: { count: number } }, - void - >(() => ({ - queryKey: ['count1Infinite'], - queryFn: async (context) => { - const count = context.pageParam ? parseInt(context.pageParam) : 0 - await new Promise((r) => (resolve = r)) - return { response: { count } } - }, - })) - - const Counter = () => { - const [data] = useAtom(countAtom) - 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] = atomsWithInfiniteQuery< - { response: { count: number } }, - void - >(() => ({ - queryKey: ['nextPageAtom'], - queryFn: async (context) => { - const count = context.pageParam ? parseInt(context.pageParam) : 0 - await new Promise((r) => (resolve = r)) - return mockFetch({ count }) - }, - getNextPageParam: (lastPage) => { - const { - response: { count }, - } = lastPage - return (count + 1).toString() - }, - getPreviousPageParam: (lastPage) => { - const { - response: { count }, - } = lastPage - return (count - 1).toString() - }, - })) - const Counter = () => { - const [data, dispatch] = useAtom(countAtom) - - 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 = () => {} - const [, slugQueryAtom] = atomsWithInfiniteQuery((get) => { - const slug = get(slugAtom) - return { - enabled: !!slug, - queryKey: ['disabled_until_value', slug], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - return { response: { slug: `hello-${slug}` } } - }, - } - }) - - const Slug = () => { - const [{ data }] = useAtom(slugQueryAtom) - if (!data?.pages?.[0]?.response.slug) return
not enabled
- 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') - - const [slugQueryAtom] = atomsWithInfiniteQuery((get) => { - const slug = get(slugAtom) - const isEnabled = get(enabledAtom) - return { - enabled: isEnabled, - queryKey: ['enabled_toggle'], - queryFn: async () => { - await new Promise((r) => setTimeout(r, 100)) // FIXME can avoid? - return { response: { slug: `hello-${slug}` } } - }, - } - }) - - const Slug = () => { - const [data] = useAtom(slugQueryAtom) - if (!data?.pages?.[0]?.response?.slug) return
not enabled
- 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') -}) - -// adapted from https://github.com/tannerlinsley/react-query/commit/f9b23fcae9c5d45e3985df4519dd8f78a9fa364e#diff-121ad879f17e2b996ac2c01b4250996c79ffdb6b7efcb5f1ddf719ac00546d14R597 -it('should be able to refetch only specific pages when refetchPages is provided', async () => { - const key = ['refetch_given_page'] - const states: any[] = [] - - let multiplier = 1 - const [anAtom] = atomsWithInfiniteQuery(() => { - return { - queryKey: key, - queryFn: ({ pageParam = 10 }) => Number(pageParam) * multiplier, - getNextPageParam: (lastPage) => lastPage + 1, - onSuccess: (data) => states.push(data), - } - }) - - function Page() { - const [state, setState] = useAtom(anAtom) - - const fetchNextPage = useCallback( - () => setState({ type: 'fetchNextPage' }), - [setState] - ) - - const refetchPage = useCallback( - (value: number) => { - multiplier = 2 - setState({ - type: 'refetch', - options: { - refetchPage: (_, index) => index === value, - }, - }) - }, - [setState] - ) - - return ( - <> -
length: {state.pages.length}
-
page 1: {state.pages[0] || null}
-
page 2: {state.pages[1] || null}
-
page 3: {state.pages[2] || null}
- - - - ) - } - - const { getByText, findByText } = render( - <> - - - - - ) - - await findByText('loading') - - await findByText('length: 1') - await findByText('page 1: 10') - - fireEvent.click(getByText('fetch next page')) - await findByText('length: 2') - await findByText('page 2: 11') - - fireEvent.click(getByText('fetch next page')) - await findByText('length: 3') - await findByText('page 3: 12') - - fireEvent.click(getByText('refetch page 1')) - await findByText('length: 3') - await findByText('page 1: 20') -}) - -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] = atomsWithInfiniteQuery(() => ({ - queryKey: ['error test', 'count1Infinite'], - retry: false, - queryFn: async (): Promise<{ response: { count: number } }> => { - await new Promise((r) => (resolve = r)) - throw new Error('fetch error') - }, - })) - const Counter = () => { - const [{ pages }] = useAtom(countAtom) - return ( - <> -
count: {pages[0]?.response.count}
- - ) - } - - const { findByText } = render( - - - - - - - - ) - - await findByText('loading') - resolve() - await findByText('errored') - }) - - it('can recover from error', async () => { - let count = -1 - let willThrowError = false - let resolve = () => {} - const [countAtom] = atomsWithInfiniteQuery< - { response: { count: number } }, - void - >(() => ({ - queryKey: ['error test', 'count2Infinite'], - retry: false, - staleTime: 200, - queryFn: async () => { - willThrowError = !willThrowError - ++count - await new Promise((r) => (resolve = r)) - if (willThrowError) { - throw new Error('fetch error') - } - return { response: { count } } - }, - })) - const Counter = () => { - const [{ pages }, dispatch] = useAtom(countAtom) - const refetch = () => dispatch({ type: 'refetch', options: {} }) - return ( - <> -
count: {pages[0]?.response.count}
- - - ) - } - - const App = () => { - const dispatch = useSetAtom(countAtom) - const retry = () => { - dispatch({ type: 'refetch', force: true, options: {} }) - } - return ( - - - - - - ) - } - - const { findByText, getByText } = render( - - - - ) - - await findByText('loading') - resolve() - await findByText('errored') - - fireEvent.click(getByText('retry')) - await findByText('loading') - resolve() - await findByText('count: 1') - - fireEvent.click(getByText('refetch')) - resolve() - // await findByText('loading') - resolve() - await findByText('errored') - - fireEvent.click(getByText('retry')) - await findByText('loading') - resolve() - await findByText('count: 3') - }) -}) diff --git a/__tests__/atomsWithQueryAsync_spec.tsx b/__tests__/atomsWithQueryAsync_spec.tsx deleted file mode 100644 index 8843891..0000000 --- a/__tests__/atomsWithQueryAsync_spec.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import type { ReactNode } from 'react' -import React, { Component, StrictMode, Suspense } from 'react' -import { QueryClient } from '@tanstack/query-core' -import { fireEvent, render } from '@testing-library/react' -import { atom, useAtom } from 'jotai' -import { atomsWithQueryAsync } from '../src/index' - -beforeEach(() => { - jest.useFakeTimers() -}) -afterEach(() => { - jest.runAllTimers() - jest.useRealTimers() -}) - -it('async query basic test', async () => { - const fn = jest.fn(() => Promise.resolve(2)) - const queryFn = jest.fn((id) => { - return Promise.resolve({ response: { id } }) - }) - - const [userAtom] = atomsWithQueryAsync(async () => { - const userId = await fn() - - return { - queryKey: ['userId', userId], - queryFn: async ({ queryKey: [, id] }) => { - const res = await queryFn(id) - return res - }, - } - }) - const User = () => { - const [ - { - response: { id }, - }, - ] = useAtom(userAtom) - - return ( - <> -
id: {id}
- - ) - } - const { findByText } = render( - - - - - - ) - - await findByText('loading') - await findByText('id: 2') -}) - -it('async query from derived atom', async () => { - const atomFn = jest.fn(() => Promise.resolve(2)) - const queryFn = jest.fn((id) => { - return Promise.resolve({ response: { id } }) - }) - - const userIdAtom = atom(async () => { - return await atomFn() - }) - const [userAtom] = atomsWithQueryAsync(async (get) => { - const userId = await get(userIdAtom) - - return { - queryKey: ['userId', userId], - queryFn: async ({ queryKey: [, id] }) => { - const res = await queryFn(id) - return res - }, - } - }) - const User = () => { - const [ - { - response: { id }, - }, - ] = useAtom(userAtom) - - return ( - <> -
id: {id}
- - ) - } - const { findByText } = render( - - - - - - ) - - await findByText('loading') - await findByText('id: 2') -}) - -it('refetch async query', async () => { - let defaultId = 0 - - const fn = jest.fn(() => Promise.resolve('uniqueKey')) - const queryFn = jest.fn((id) => { - return Promise.resolve({ response: { id } }) - }) - - const [userAtom] = atomsWithQueryAsync(async () => { - const extraKey = await fn() - - return { - queryKey: ['userId', extraKey], - queryFn: async () => { - const res = await queryFn(defaultId) - defaultId++ - return res - }, - } - }) - - const User = () => { - const [ - { - response: { id }, - }, - dispatch, - ] = useAtom(userAtom) - return ( - <> -
id: {id}
- - - ) - } - - const { findByText, getByText } = render( - - - - - - ) - - await findByText('loading') - await findByText('id: 0') - - fireEvent.click(getByText('refetch')) - await findByText('id: 1') - - fireEvent.click(getByText('refetch')) - await findByText('id: 2') - - fireEvent.click(getByText('refetch')) - await findByText('id: 3') -}) - -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() { - if (this.state.hasError) { - return ( -
- {this.props.message || 'errored'} - -
- ) - } - - return this.props.children - } - } - - it('can catch error in error boundary', async () => { - const fn = jest.fn(() => Promise.resolve('uniqueKey')) - const queryFn = jest.fn(() => { - return Promise.resolve() - }) - - const [userAtom] = atomsWithQueryAsync(async () => { - const extraKey = await fn() - - return { - queryKey: ['error test', extraKey], - retry: false, - queryFn: async (): Promise<{ response: { id: number } }> => { - await queryFn() - throw new Error('fetch error') - }, - } - }) - - const User = () => { - const [ - { - response: { id }, - }, - ] = useAtom(userAtom) - return ( - <> -
id: {id}
- - ) - } - - const { findByText } = render( - - - - - - - - ) - - await findByText('loading') - await findByText('errored') - }) -}) - -it('query expected QueryCache test', async () => { - const queryClient = new QueryClient() - - const fn = jest.fn(() => Promise.resolve('uniqueKey')) - const queryFn = jest.fn(() => { - return Promise.resolve(2) - }) - - const [userAtom] = atomsWithQueryAsync( - async () => { - const extraKey = await fn() - - return { - queryKey: [extraKey], - queryFn: async () => { - const id = await queryFn() - return { response: { id } } - }, - } - }, - () => queryClient - ) - - const User = () => { - const [ - { - response: { id }, - }, - ] = useAtom(userAtom) - - return ( - <> -
id: {id}
- - ) - } - - const { findByText } = render( - - - - - - ) - - await findByText('loading') - await findByText('id: 2') - expect(queryClient.getQueryCache().getAll().length).toBe(1) -}) diff --git a/__tests__/issue_9_spec.tsx b/__tests__/issue_9_spec.tsx deleted file mode 100644 index 226cd2c..0000000 --- a/__tests__/issue_9_spec.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' -import { fireEvent, render } from '@testing-library/react' -import { useAtom } from 'jotai/react' -import { atom } from 'jotai/vanilla' -import { atomsWithQuery } from '../src/index' - -describe('issue #9', () => { - it('status should change', async () => { - const idAtom = atom(undefined as number | undefined) - - let resolve: (() => void) | undefined - const [, statusAtom] = atomsWithQuery((get) => ({ - queryKey: ['users', get(idAtom)], - queryFn: async ({ queryKey: [, id] }) => { - await new Promise((r) => { - resolve = r - }) - return { id } - }, - // using as a dependent query - enabled: Boolean(get(idAtom)), - })) - - const DataFromStatusAtom = () => { - const [status] = useAtom(statusAtom) - const statusMessage = status.isInitialLoading - ? 'initial loading' - : status.status - return ( -
-
status: {statusMessage}
-
data: {JSON.stringify(status.data)}
-
- ) - } - - const Controls = () => { - const [id, setId] = useAtom(idAtom) - return ( -
- ID: {id}{' '} - -
- ) - } - - const App = () => ( - <> - - - - ) - - const { findByText, getByText } = render() - - await findByText('status: loading') - - fireEvent.click(getByText('Next')) - await findByText('status: initial loading') - resolve?.() - await findByText('status: success') - - fireEvent.click(getByText('Next')) - await findByText('status: initial loading') - resolve?.() - await findByText('status: success') - }) -}) diff --git a/examples/01_typescript/src/App.tsx b/examples/01_typescript/src/App.tsx index d30f3a3..bcfc7a4 100644 --- a/examples/01_typescript/src/App.tsx +++ b/examples/01_typescript/src/App.tsx @@ -1,11 +1,11 @@ -import React, { Suspense } from 'react' +import React from 'react' import { useAtom } from 'jotai/react' import { atom } from 'jotai/vanilla' -import { atomsWithQuery } from 'jotai-tanstack-query' +import { atomWithQuery } from 'jotai-tanstack-query' const idAtom = atom(1) -const [userAtom] = atomsWithQuery((get) => ({ +const userAtom = atomWithQuery((get) => ({ queryKey: ['users', get(idAtom)], queryFn: async ({ queryKey: [, id] }) => { const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`) @@ -14,7 +14,11 @@ const [userAtom] = atomsWithQuery((get) => ({ })) const UserData = () => { - const [data] = useAtom(userAtom) + const [{ data, isPending, isError }] = useAtom(userAtom) + + if (isPending) return
Loading...
+ if (isError) return
Error
+ return
{JSON.stringify(data)}
} @@ -36,9 +40,7 @@ const Controls = () => { const App = () => ( <> - - - + ) diff --git a/examples/02_refetch/package.json b/examples/02_typescript_suspense/package.json similarity index 100% rename from examples/02_refetch/package.json rename to examples/02_typescript_suspense/package.json diff --git a/examples/02_refetch/public/index.html b/examples/02_typescript_suspense/public/index.html similarity index 100% rename from examples/02_refetch/public/index.html rename to examples/02_typescript_suspense/public/index.html diff --git a/examples/02_typescript_suspense/src/App.tsx b/examples/02_typescript_suspense/src/App.tsx new file mode 100644 index 0000000..0b47e1e --- /dev/null +++ b/examples/02_typescript_suspense/src/App.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { useAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import { atomWithQuery } from 'jotai-tanstack-query' + +const idAtom = atom(1) + +const userAtom = atomWithQuery((get) => ({ + queryKey: ['users', get(idAtom)], + queryFn: async ({ queryKey: [, id] }) => { + const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`) + return res.json() + }, +})) + +const UserData = () => { + const [{ data, isPending, isError }] = useAtom(userAtom) + + if (isPending) return
Loading...
+ if (isError) return
Error
+ + return
{JSON.stringify(data)}
+} + +const Controls = () => { + const [id, setId] = useAtom(idAtom) + return ( +
+ ID: {id}{' '} + {' '} + +
+ ) +} + +const App = () => ( + <> + + + +) + +export default App diff --git a/examples/02_refetch/src/index.tsx b/examples/02_typescript_suspense/src/index.tsx similarity index 100% rename from examples/02_refetch/src/index.tsx rename to examples/02_typescript_suspense/src/index.tsx diff --git a/examples/03_infinite/src/App.tsx b/examples/03_infinite/src/App.tsx index e92e197..27a230a 100644 --- a/examples/03_infinite/src/App.tsx +++ b/examples/03_infinite/src/App.tsx @@ -1,8 +1,9 @@ -import React, { Suspense } from 'react' +import React from 'react' import { useAtom } from 'jotai/react' -import { atomsWithInfiniteQuery } from 'jotai-tanstack-query' +import { atomWithInfiniteQuery } from 'jotai-tanstack-query' -const [postsAtom] = atomsWithInfiniteQuery(() => ({ +const postsAtom = atomWithInfiniteQuery(() => ({ + initialPageParam: 1, queryKey: ['posts'], queryFn: async ({ pageParam = 1 }) => { const res = await fetch( @@ -15,24 +16,19 @@ const [postsAtom] = atomsWithInfiniteQuery(() => ({ })) const Posts = () => { - const [data, dispatch] = useAtom(postsAtom) + const [{ data, fetchNextPage }] = useAtom(postsAtom) + return (
- -
    - {data.pages.map((item) => ( -
  • {item.title}
  • - ))} -
+ +
    {data?.pages.map((item) =>
  • {item.title}
  • )}
) } const App = () => ( <> - - - + ) diff --git a/examples/04_mutation/package.json b/examples/04_infinite_suspense/package.json similarity index 100% rename from examples/04_mutation/package.json rename to examples/04_infinite_suspense/package.json diff --git a/examples/04_mutation/public/index.html b/examples/04_infinite_suspense/public/index.html similarity index 100% rename from examples/04_mutation/public/index.html rename to examples/04_infinite_suspense/public/index.html diff --git a/examples/04_infinite_suspense/src/App.tsx b/examples/04_infinite_suspense/src/App.tsx new file mode 100644 index 0000000..d9a76ff --- /dev/null +++ b/examples/04_infinite_suspense/src/App.tsx @@ -0,0 +1,40 @@ +import React, { Suspense } from 'react' +import { useAtom } from 'jotai/react' +import { atomWithSuspenseInfiniteQuery } from 'jotai-tanstack-query' + +const postsAtom = atomWithSuspenseInfiniteQuery(() => ({ + initialPageParam: 1, + queryKey: ['posts'], + queryFn: async ({ pageParam = 1 }) => { + const res = await fetch( + `https://jsonplaceholder.typicode.com/posts/${pageParam}` + ) + const data: { id: number; title: string } = await res.json() + return data + }, + getNextPageParam: (lastPage) => lastPage.id + 1, +})) + +const Posts = () => { + const [{ data, fetchNextPage }] = useAtom(postsAtom) + return ( +
+ +
    + {data.pages.map((item) => ( +
  • {item.title}
  • + ))} +
+
+ ) +} + +const App = () => ( + <> + + + + +) + +export default App diff --git a/examples/04_mutation/src/index.tsx b/examples/04_infinite_suspense/src/index.tsx similarity index 100% rename from examples/04_mutation/src/index.tsx rename to examples/04_infinite_suspense/src/index.tsx diff --git a/examples/05_async/src/App.tsx b/examples/05_async/src/App.tsx deleted file mode 100644 index 09f8cbd..0000000 --- a/examples/05_async/src/App.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { atom, useAtomValue } from 'jotai' -import { atomsWithQueryAsync } from 'jotai-tanstack-query' - -const idAtom = atom(async () => { - await new Promise((resolve) => setTimeout(resolve, 2000)) - return 2 -}) - -const [userAtom] = atomsWithQueryAsync(async (get) => { - const id = await get(idAtom) - return { - queryKey: ['getUser', id], - queryFn: async () => { - const res = await fetch('https://reqres.in/api/users/' + id) - return res.json() as Promise<{ data: unknown }> - }, - } -}) - -const UserData = () => { - const data = useAtomValue(userAtom) - return
{JSON.stringify(data)}
-} - -const App = () => ( - - - -) - -export default App diff --git a/examples/05_async/package.json b/examples/05_mutation/package.json similarity index 100% rename from examples/05_async/package.json rename to examples/05_mutation/package.json diff --git a/examples/05_async/public/index.html b/examples/05_mutation/public/index.html similarity index 100% rename from examples/05_async/public/index.html rename to examples/05_mutation/public/index.html diff --git a/examples/04_mutation/src/App.tsx b/examples/05_mutation/src/App.tsx similarity index 74% rename from examples/04_mutation/src/App.tsx rename to examples/05_mutation/src/App.tsx index 86e673a..36af207 100644 --- a/examples/04_mutation/src/App.tsx +++ b/examples/05_mutation/src/App.tsx @@ -1,8 +1,8 @@ import React from 'react' import { useAtom } from 'jotai/react' -import { atomsWithMutation } from 'jotai-tanstack-query' +import { atomWithMutation } from 'jotai-tanstack-query' -const [, statusAtom] = atomsWithMutation(() => ({ +const postAtom = atomWithMutation(() => ({ mutationKey: ['posts'], mutationFn: async ({ title }: { title: string }) => { const res = await fetch(`https://jsonplaceholder.typicode.com/posts`, { @@ -22,10 +22,10 @@ const [, statusAtom] = atomsWithMutation(() => ({ })) const Posts = () => { - const [status, dispatch] = useAtom(statusAtom) + const [{ mutate, status }] = useAtom(postAtom) return (
- +
{JSON.stringify(status, null, 2)}
) diff --git a/examples/05_async/src/index.tsx b/examples/05_mutation/src/index.tsx similarity index 100% rename from examples/05_async/src/index.tsx rename to examples/05_mutation/src/index.tsx diff --git a/examples/06_refetch/package.json b/examples/06_refetch/package.json new file mode 100644 index 0000000..4d5bbef --- /dev/null +++ b/examples/06_refetch/package.json @@ -0,0 +1,28 @@ +{ + "name": "jotai-tanstack-query-example", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tanstack/query-core": "latest", + "@types/react": "latest", + "@types/react-dom": "latest", + "jotai": "latest", + "jotai-tanstack-query": "latest", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "typescript": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ] +} diff --git a/examples/06_refetch/public/index.html b/examples/06_refetch/public/index.html new file mode 100644 index 0000000..a4804d6 --- /dev/null +++ b/examples/06_refetch/public/index.html @@ -0,0 +1,8 @@ + + + jotai-tanstack-query example + + +
+ + diff --git a/examples/02_refetch/src/App.tsx b/examples/06_refetch/src/App.tsx similarity index 65% rename from examples/02_refetch/src/App.tsx rename to examples/06_refetch/src/App.tsx index aac6307..75b7d09 100644 --- a/examples/02_refetch/src/App.tsx +++ b/examples/06_refetch/src/App.tsx @@ -1,13 +1,13 @@ -import React, { Suspense } from 'react' -import { useAtom, useSetAtom } from 'jotai/react' +import React from 'react' +import { useAtom } from 'jotai/react' import { atom } from 'jotai/vanilla' -import { atomsWithQuery } from 'jotai-tanstack-query' +import { atomWithQuery } from 'jotai-tanstack-query' import { ErrorBoundary } from 'react-error-boundary' import type { FallbackProps } from 'react-error-boundary' const idAtom = atom(1) -const [userAtom] = atomsWithQuery((get) => ({ +const userAtom = atomWithQuery((get) => ({ queryKey: ['users', get(idAtom)], queryFn: async ({ queryKey: [, id] }) => { const res = await fetch(`https://reqres.in/api/users/${id}`) @@ -16,7 +16,8 @@ const [userAtom] = atomsWithQuery((get) => ({ })) const UserData = () => { - const [{ data }, dispatch] = useAtom(userAtom) + const [{ data, refetch, isPending }] = useAtom(userAtom) + if (isPending) return
Loading...
return (
    @@ -24,7 +25,7 @@ const UserData = () => {
  • First Name: {data.first_name}
  • Last Name: {data.last_name}
- +
) } @@ -45,18 +46,11 @@ const Controls = () => { } const Fallback = ({ error, resetErrorBoundary }: FallbackProps) => { - const setId = useSetAtom(idAtom) - const dispatch = useSetAtom(userAtom) - const retry = () => { - setId(1) - dispatch({ type: 'refetch', force: true }) - resetErrorBoundary() - } return (

Something went wrong:

{error.message}
-
@@ -65,10 +59,8 @@ const Fallback = ({ error, resetErrorBoundary }: FallbackProps) => { const App = () => ( - - - - + + ) diff --git a/examples/06_refetch/src/index.tsx b/examples/06_refetch/src/index.tsx new file mode 100644 index 0000000..98efb8a --- /dev/null +++ b/examples/06_refetch/src/index.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' + +const ele = document.getElementById('app') +if (ele) { + createRoot(ele).render(React.createElement(App)) +} diff --git a/package.json b/package.json index 17a3e9b..44e46ad 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "0.7.2", "author": "Daishi Kato", "contributors": [ - "Mohammad Bagher Abiat" + "Mohammad Bagher Abiat", + "Kali Charan Reddy Jonna" ], "repository": { "type": "git", @@ -29,17 +30,18 @@ "dist" ], "scripts": { - "compile": "microbundle build -f modern,umd --globals react=React", + "compile": "microbundle build -f modern,umd --globals react=React --jsx React.createElement --jsxFragment React.Fragment", "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", "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", + "examples:02_typescript_suspense": "DIR=02_typescript_suspense EXT=tsx webpack serve", "examples:03_infinite": "DIR=03_infinite EXT=tsx webpack serve", - "examples:04_mutation": "DIR=04_mutation EXT=tsx webpack serve", - "examples:05_async": "DIR=05_async EXT=tsx webpack serve" + "examples:04_infinite_suspense": "DIR=04_infinite_suspense EXT=tsx webpack serve", + "examples:05_mutation": "DIR=05_mutation EXT=tsx webpack serve", + "examples:06_refetch": "DIR=06_refetch EXT=tsx webpack serve" }, "jest": { "testEnvironment": "jsdom", @@ -53,7 +55,7 @@ ], "license": "MIT", "devDependencies": { - "@tanstack/query-core": "^4.35.2", + "@tanstack/query-core": "^5.12.1", "@testing-library/react": "^14.0.0", "@types/jest": "^29.5.4", "@types/node": "^20.5.9", @@ -84,10 +86,12 @@ "typescript": "^5.2.2", "webpack": "^5.88.2", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1" + "webpack-dev-server": "^4.15.1", + "wonka": "^6.3.4" }, "peerDependencies": { "@tanstack/query-core": "*", - "jotai": ">=1.11.0" + "jotai": ">=1.11.0", + "wonka": "^6.3.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbd23ca..1b2985d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,13 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + devDependencies: '@tanstack/query-core': - specifier: ^4.35.2 - version: 4.35.2 + 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) @@ -97,6 +101,9 @@ devDependencies: webpack-dev-server: specifier: ^4.15.1 version: 4.15.1(webpack-cli@5.1.4)(webpack@5.88.2) + wonka: + specifier: ^6.3.4 + version: 6.3.4 packages: @@ -1881,8 +1888,8 @@ packages: string.prototype.matchall: 4.0.9 dev: true - /@tanstack/query-core@4.35.2: - resolution: {integrity: sha512-IJYT+VVx0SGe3QWqL6XUgzEY1re3szCdGMSGD1VRdEej6Uq3O+qELR2Ypw1/a4bUYKXiDUpK4BAcBynWb6nbkQ==} + /@tanstack/query-core@5.12.1: + resolution: {integrity: sha512-WbZztNmKq0t6QjdNmHzezbi/uifYo9j6e2GLJkodsYaYUlzMbAp91RDyeHkIZrm7EfO4wa6Sm5sxJZm5SPlh6w==} dev: true /@testing-library/dom@9.3.1: @@ -8848,6 +8855,10 @@ packages: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} dev: true + /wonka@6.3.4: + resolution: {integrity: sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==} + dev: true + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} diff --git a/src/atomWithInfiniteQuery.ts b/src/atomWithInfiniteQuery.ts new file mode 100644 index 0000000..77d346d --- /dev/null +++ b/src/atomWithInfiniteQuery.ts @@ -0,0 +1,125 @@ +import { InfiniteQueryObserver, QueryClient } from '@tanstack/query-core' +import type { + DefaultError, + DefaultedInfiniteQueryObserverOptions, + InfiniteData, + InfiniteQueryObserverOptions, + InfiniteQueryObserverResult, + QueryKey, + WithRequired, +} from '@tanstack/query-core' +import { Atom, type Getter, atom } from 'jotai/vanilla' +import { baseAtomWithQuery } from './baseAtomWithQuery' +import { queryClientAtom } from './queryClientAtom' + +export function atomWithInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + getOptions: ( + get: Getter + ) => InfiniteQueryOptions, + getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) +): Atom> { + const IN_RENDER = Symbol() + + const observerCacheAtom = atom( + () => + new WeakMap< + QueryClient, + InfiniteQueryObserver< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + > + >() + ) + if (process.env.NODE_ENV !== 'production') { + observerCacheAtom.debugPrivate = true + } + const optionsAtom = atom((get) => { + const client = getQueryClient(get) + const options = getOptions(get) + const dOptions = client.defaultQueryOptions( + options + ) as DefaultedInfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + > + + dOptions._optimisticResults = 'optimistic' + + return dOptions + }) + if (process.env.NODE_ENV !== 'production') { + optionsAtom.debugPrivate = true + } + const observerAtom = atom((get) => { + const options = get(optionsAtom) + const client = getQueryClient(get) + + const observerCache = get(observerCacheAtom) + + const observer = observerCache.get(client) + + if (observer) { + ;(observer as any)[IN_RENDER] = true + observer.setOptions(options, { listeners: false }) + delete (observer as any)[IN_RENDER] + + return observer + } + + const newObserver = new InfiniteQueryObserver(client, options) + observerCache.set(client, newObserver) + + return newObserver + }) + + return baseAtomWithQuery< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >( + (get) => ({ + ...get(optionsAtom), + suspense: false, + }), + (get) => get(observerAtom), + getQueryClient + ) +} + +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' + > {} diff --git a/src/atomWithMutation.ts b/src/atomWithMutation.ts new file mode 100644 index 0000000..c15e85c --- /dev/null +++ b/src/atomWithMutation.ts @@ -0,0 +1,148 @@ +import { + MutationObserver, + type MutationObserverOptions, + type MutationObserverResult, + QueryClient, +} from '@tanstack/query-core' +import { Getter, atom } from 'jotai' +import { make, pipe, toObservable } from 'wonka' +import { queryClientAtom } from './queryClientAtom' +import { shouldThrowError } from './utils' + +export function atomWithMutation< + TData = unknown, + TVariables = void, + TError = unknown, + TContext = unknown, +>( + getOptions: ( + get: Getter + ) => MutationObserverOptions, + getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) +) { + const IN_RENDER = Symbol() + + const optionsAtom = atom((get) => { + const client = getQueryClient(get) + const options = getOptions(get) + return client.defaultMutationOptions(options) + }) + if (process.env.NODE_ENV !== 'production') { + optionsAtom.debugPrivate = true + } + + const observerCacheAtom = atom( + () => + new WeakMap< + QueryClient, + MutationObserver + >() + ) + if (process.env.NODE_ENV !== 'production') { + observerCacheAtom.debugPrivate = true + } + + const observerAtom = atom((get) => { + const options = get(optionsAtom) + const client = getQueryClient(get) + const observerCache = get(observerCacheAtom) + + const observer = observerCache.get(client) + + if (observer) { + ;(observer as any)[IN_RENDER] = true + observer.setOptions(options) + delete (observer as any)[IN_RENDER] + + return observer + } + + const newObserver = new MutationObserver(client, options) + observerCache.set(client, newObserver) + + return newObserver + }) + if (process.env.NODE_ENV !== 'production') { + observerAtom.debugPrivate = true + } + + const observableAtom = atom((get) => { + const observer = get(observerAtom) + const source = make< + MutationObserverResult + >(({ next }) => { + const callback = ( + result: MutationObserverResult + ) => { + const notifyResult = () => next(result) + + if ((observer as any)[IN_RENDER]) { + Promise.resolve().then(notifyResult) + } else { + notifyResult() + } + } + + return observer.subscribe(callback) + }) + return pipe(source, toObservable) + }) + + const dataAtom = atom((get) => { + const observer = get(observerAtom) + const observable = get(observableAtom) + + const currentResult = observer.getCurrentResult() + const resultAtom = atom(currentResult) + + resultAtom.onMount = (set) => { + const { unsubscribe } = observable.subscribe((state) => { + set(state) + }) + return () => { + unsubscribe + observer.reset() + } + } + if (process.env.NODE_ENV !== 'production') { + resultAtom.debugPrivate = true + } + + return resultAtom + }) + + const mutateAtom = atom((get) => { + const observer = get(observerAtom) + const mutate = ( + variables: TVariables, + options?: MutationObserverOptions + ) => { + observer.mutate(variables, options).catch(noop) + } + + return mutate + }) + if (process.env.NODE_ENV !== 'production') { + mutateAtom.debugPrivate = true + } + + return atom((get) => { + const observer = get(observerAtom) + const resultAtom = get(dataAtom) + + const result = get(resultAtom) + const mutate = get(mutateAtom) + + if ( + result.isError && + shouldThrowError(observer.options.throwOnError, [result.error]) + ) { + throw result.error + } + + return { ...result, mutate, mutateAsync: result.mutate } + }) +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +function noop() {} diff --git a/src/atomWithMutationState.ts b/src/atomWithMutationState.ts new file mode 100644 index 0000000..f806ce5 --- /dev/null +++ b/src/atomWithMutationState.ts @@ -0,0 +1,62 @@ +import { + DefaultError, + Mutation, + MutationCache, + MutationFilters, + MutationState, + QueryClient, +} from '@tanstack/query-core' +import { Getter, atom } from 'jotai' +import { queryClientAtom } from './queryClientAtom' + +type MutationStateOptions = { + filters?: MutationFilters + select?: ( + mutation: Mutation + ) => TResult +} + +function getResult( + mutationCache: MutationCache, + options: MutationStateOptions +): Array { + return mutationCache + .findAll({ ...options.filters, status: 'pending' }) + .map( + (mutation): TResult => + (options.select + ? options.select( + mutation as Mutation + ) + : mutation.state) as TResult + ) +} + +export const atomWithMutationState = ( + getOptions: (get: Getter) => MutationStateOptions, + getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) +) => { + const resultsAtom = atom([]) + if (process.env.NODE_ENV !== 'production') { + resultsAtom.debugPrivate = true + } + + const observableAtom = atom((get) => { + const queryClient = getQueryClient(get) + + const mutationCache = queryClient.getMutationCache() + resultsAtom.onMount = (set) => { + mutationCache.subscribe(() => { + set(getResult(getQueryClient(get).getMutationCache(), getOptions(get))) + }) + } + }) + if (process.env.NODE_ENV !== 'production') { + observableAtom.debugPrivate = true + } + + return atom((get) => { + get(observableAtom) + return get(resultsAtom) + }) +} diff --git a/src/atomWithQuery.ts b/src/atomWithQuery.ts new file mode 100644 index 0000000..d926121 --- /dev/null +++ b/src/atomWithQuery.ts @@ -0,0 +1,84 @@ +import { + DefaultError, + QueryClient, + QueryKey, + QueryObserver, + QueryObserverOptions, + QueryObserverResult, +} from '@tanstack/query-core' +import { Atom, Getter, atom } from 'jotai' +import { baseAtomWithQuery } from './baseAtomWithQuery' +import { queryClientAtom } from './queryClientAtom' + +export function atomWithQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + getOptions: ( + get: Getter + ) => Omit< + QueryObserverOptions, + 'suspense' + >, + getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) +): Atom> { + const IN_RENDER = Symbol() + + const observerCacheAtom = atom( + () => + new WeakMap< + QueryClient, + QueryObserver + >() + ) + if (process.env.NODE_ENV !== 'production') { + observerCacheAtom.debugPrivate = true + } + + const optionsAtom = atom((get) => { + const client = getQueryClient(get) + const options = getOptions(get) + const dOptions = client.defaultQueryOptions(options) + + dOptions._optimisticResults = 'optimistic' + + return dOptions + }) + if (process.env.NODE_ENV !== 'production') { + optionsAtom.debugPrivate = true + } + + const observerAtom = atom((get) => { + const options = get(optionsAtom) + const client = getQueryClient(get) + + const observerCache = get(observerCacheAtom) + + const observer = observerCache.get(client) + + if (observer) { + ;(observer as any)[IN_RENDER] = true + observer.setOptions(options) + delete (observer as any)[IN_RENDER] + + return observer + } + + const newObserver = new QueryObserver(client, options) + observerCache.set(client, newObserver) + + return newObserver + }) + if (process.env.NODE_ENV !== 'production') { + observerAtom.debugPrivate = true + } + + return baseAtomWithQuery( + (get) => ({ ...get(optionsAtom), suspense: false }), + (get) => get(observerAtom), + getQueryClient + ) +} diff --git a/src/atomWithSuspenseInfiniteQuery.ts b/src/atomWithSuspenseInfiniteQuery.ts new file mode 100644 index 0000000..ffffee0 --- /dev/null +++ b/src/atomWithSuspenseInfiniteQuery.ts @@ -0,0 +1,134 @@ +import { + DefaultError, + DefaultedInfiniteQueryObserverOptions, + InfiniteData, + InfiniteQueryObserver, + InfiniteQueryObserverOptions, + InfiniteQueryObserverSuccessResult, + QueryClient, + type QueryKey, +} from '@tanstack/query-core' +import { Atom, Getter, atom } from 'jotai' +import { baseAtomWithQuery } from './baseAtomWithQuery' +import { queryClientAtom } from './queryClientAtom' + +export const atomWithSuspenseInfiniteQuery = < + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + getOptions: ( + get: Getter + ) => SuspenseInfiniteQueryOptions< + TQueryFnData, + TError, + TPageParam, + TData, + TQueryKey + >, + getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) +): Atom>> => { + const IN_RENDER = Symbol() + + const observerCacheAtom = atom( + () => + new WeakMap< + QueryClient, + InfiniteQueryObserver< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + > + >() + ) + if (process.env.NODE_ENV !== 'production') { + observerCacheAtom.debugPrivate = true + } + + const optionsAtom = atom((get) => { + const client = getQueryClient(get) + const options = getOptions(get) + const dOptions = client.defaultQueryOptions( + options + ) as DefaultedInfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + > + + dOptions._optimisticResults = 'optimistic' + + return dOptions + }) + if (process.env.NODE_ENV !== 'production') { + optionsAtom.debugPrivate = true + } + + const observerAtom = atom((get) => { + const options = get(optionsAtom) + const client = getQueryClient(get) + + const observerCache = get(observerCacheAtom) + + const observer = observerCache.get(client) + + if (observer) { + ;(observer as any)[IN_RENDER] = true + observer.setOptions(options) + delete (observer as any)[IN_RENDER] + + return observer + } + + const newObserver = new InfiniteQueryObserver(client, options) + observerCache.set(client, newObserver) + + return newObserver + }) + if (process.env.NODE_ENV !== 'production') { + observerAtom.debugPrivate = true + } + + return baseAtomWithQuery< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >( + (get) => ({ + ...get(optionsAtom), + suspense: true, + enabled: true, + }), + (get) => get(observerAtom), + getQueryClient + ) +} + +interface SuspenseInfiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TPageParam = unknown, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, +> extends Omit< + InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + 'enabled' | 'throwOnError' | 'placeholderData' + > {} diff --git a/src/atomWithSuspenseQuery.ts b/src/atomWithSuspenseQuery.ts new file mode 100644 index 0000000..97fc20b --- /dev/null +++ b/src/atomWithSuspenseQuery.ts @@ -0,0 +1,91 @@ +import { + DefaultError, + type DefinedQueryObserverResult, + QueryClient, + type QueryKey, + QueryObserver, + type QueryObserverOptions, +} from '@tanstack/query-core' +import { Atom, Getter, atom } from 'jotai' +import { baseAtomWithQuery } from './baseAtomWithQuery' +import { queryClientAtom } from './queryClientAtom' + +export const atomWithSuspenseQuery = < + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + getOptions: ( + get: Getter + ) => Omit< + QueryObserverOptions, + 'suspense' | 'enabled' + >, + getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) +): Atom< + | DefinedQueryObserverResult + | Promise> +> => { + const IN_RENDER = Symbol() + + const observerCacheAtom = atom( + () => + new WeakMap< + QueryClient, + QueryObserver + >() + ) + if (process.env.NODE_ENV !== 'production') { + observerCacheAtom.debugPrivate = true + } + + const optionsAtom = atom((get) => { + const client = getQueryClient(get) + const options = getOptions(get) + const dOptions = client.defaultQueryOptions(options) + + dOptions._optimisticResults = 'optimistic' + + return dOptions + }) + if (process.env.NODE_ENV !== 'production') { + optionsAtom.debugPrivate = true + } + + const observerAtom = atom((get) => { + const options = get(optionsAtom) + const client = getQueryClient(get) + + const observerCache = get(observerCacheAtom) + + const observer = observerCache.get(client) + + if (observer) { + ;(observer as any)[IN_RENDER] = true + observer.setOptions(options) + delete (observer as any)[IN_RENDER] + + return observer + } + + const newObserver = new QueryObserver(client, options) + observerCache.set(client, newObserver) + + return newObserver + }) + if (process.env.NODE_ENV !== 'production') { + observerAtom.debugPrivate = true + } + + return baseAtomWithQuery( + (get) => ({ + ...get(optionsAtom), + suspense: true, + enabled: true, + }), + (get) => get(observerAtom), + getQueryClient + ) +} diff --git a/src/atomsWithInfiniteQuery.ts b/src/atomsWithInfiniteQuery.ts deleted file mode 100644 index ced4c5e..0000000 --- a/src/atomsWithInfiniteQuery.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { InfiniteQueryObserver, QueryClient } from '@tanstack/query-core' -import type { - InfiniteData, - InfiniteQueryObserverOptions, - InfiniteQueryObserverResult, - QueryKey, - QueryObserverResult, -} from '@tanstack/query-core' -import type { Getter, WritableAtom } from 'jotai/vanilla' -import { createAtoms } from './common' -import { queryClientAtom } from './queryClientAtom' - -type Action = - | { - type: 'refetch' - force?: boolean - options?: Parameters[0] - } - | { type: 'fetchNextPage' } - | { type: 'fetchPreviousPage' } - -export function atomsWithInfiniteQuery< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData, - TQueryData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, ->( - getOptions: ( - get: Getter - ) => InfiniteQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - >, - getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) -): readonly [ - dataAtom: WritableAtom< - InfiniteData | Promise>, - [Action], - Promise, TError>> | undefined - >, - statusAtom: WritableAtom< - InfiniteQueryObserverResult, - [Action], - Promise, TError>> | undefined - >, -] { - return createAtoms( - getOptions, - getQueryClient, - (client, options) => new InfiniteQueryObserver(client, options), - (action, observer, refresh) => { - if (action.type === 'refetch') { - if (action.force) { - observer.remove() - refresh() - return - } - return observer.refetch(action.options) - } - if (action.type === 'fetchNextPage') { - return observer.fetchNextPage() - } - if (action.type === 'fetchPreviousPage') { - return observer.fetchPreviousPage() - } - } - ) -} diff --git a/src/atomsWithMutation.ts b/src/atomsWithMutation.ts deleted file mode 100644 index f89eac8..0000000 --- a/src/atomsWithMutation.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { MutationObserver, QueryClient } from '@tanstack/query-core' -import type { - MutateOptions, - MutationObserverOptions, - MutationObserverResult, -} from '@tanstack/query-core' -import type { Getter, WritableAtom } from 'jotai/vanilla' -import { createAtoms } from './common' -import { queryClientAtom } from './queryClientAtom' - -type Action = [ - variables: TVariables, - options?: MutateOptions, -] - -export function atomsWithMutation< - TData = unknown, - TError = unknown, - TVariables = void, - TContext = unknown, ->( - getOptions: ( - get: Getter - ) => MutationObserverOptions, - getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) -): readonly [ - dataAtom: WritableAtom< - TData | Promise, - [Action], - Promise - >, - statusAtom: WritableAtom< - MutationObserverResult, - [Action], - Promise - >, -] { - return createAtoms( - getOptions, - getQueryClient, - (client, options) => new MutationObserver(client, options), - (action, observer) => observer.mutate(...action) - ) -} diff --git a/src/atomsWithQuery.ts b/src/atomsWithQuery.ts deleted file mode 100644 index 9a2206c..0000000 --- a/src/atomsWithQuery.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { QueryClient, QueryObserver } from '@tanstack/query-core' -import type { - QueryKey, - QueryObserverOptions, - QueryObserverResult, -} from '@tanstack/query-core' -import type { Getter, WritableAtom } from 'jotai/vanilla' -import { createAtoms } from './common' -import { queryClientAtom } from './queryClientAtom' - -type Action = { - type: 'refetch' - force?: boolean - options?: Parameters[0] -} - -export function atomsWithQuery< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData, - TQueryData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, ->( - getOptions: ( - get: Getter - ) => QueryObserverOptions, - getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) -): readonly [ - dataAtom: WritableAtom< - TData | Promise, - [Action], - Promise> | undefined - >, - statusAtom: WritableAtom< - QueryObserverResult, - [Action], - Promise> | undefined - >, -] { - return createAtoms( - getOptions, - getQueryClient, - (client, options) => new QueryObserver(client, options), - (action, observer, refresh) => { - if (action.type === 'refetch') { - if (action.force) { - observer.remove() - refresh() - return - } - return observer.refetch(action.options) - } - } - ) -} diff --git a/src/atomsWithQueryAsync.ts b/src/atomsWithQueryAsync.ts deleted file mode 100644 index c4db631..0000000 --- a/src/atomsWithQueryAsync.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { - QueryKey, - QueryObserverOptions, - QueryObserverResult, -} from '@tanstack/query-core' -import { QueryClient, QueryObserver } from '@tanstack/query-core' -import type { Getter, WritableAtom } from 'jotai' -import { createAsyncAtoms } from './common' -import { queryClientAtom } from './queryClientAtom' - -type Action = { - type: 'refetch' - force?: boolean - options?: Parameters[0] -} - -export function atomsWithQueryAsync< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData, - TQueryData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, ->( - getOptions: ( - get: Getter - ) => Promise< - QueryObserverOptions - >, - getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) -): readonly [ - dataAtom: WritableAtom< - Promise, - [Action], - Promise> | undefined - >, - statusAtom: WritableAtom< - QueryObserverResult, - [Action], - Promise> | undefined - >, -] { - return createAsyncAtoms( - getOptions, - getQueryClient, - (client, options) => new QueryObserver(client, options), - async (action: Action, observer, refresh) => { - if (action.type === 'refetch') { - if (action.force) { - observer.remove() - refresh() - return - } - return await observer.refetch(action.options) - } - } - ) as any -} diff --git a/src/baseAtomWithQuery.ts b/src/baseAtomWithQuery.ts new file mode 100644 index 0000000..15327dd --- /dev/null +++ b/src/baseAtomWithQuery.ts @@ -0,0 +1,228 @@ +import { + DefaultError, + DefaultedInfiniteQueryObserverOptions, + DefaultedQueryObserverOptions, + InfiniteQueryObserver, + InfiniteQueryObserverResult, + InfiniteQueryObserverSuccessResult, + QueryClient, + QueryKey, + QueryObserver, + QueryObserverResult, + QueryObserverSuccessResult, +} from '@tanstack/query-core' +import { Atom, Getter, atom } from 'jotai' +import { make, pipe, toObservable } from 'wonka' +import { queryClientAtom } from './queryClientAtom' +import { getHasError, shouldSuspend } from './utils' + +export function baseAtomWithQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + getOptions: ( + get: Getter + ) => DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > & { suspense: true; enabled: true }, + getObserver: ( + get: Getter + ) => QueryObserver, + getQueryClient?: (get: Getter) => QueryClient +): Atom>> +export function baseAtomWithQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + getOptions: ( + get: Getter + ) => DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > & { suspense: false }, + getObserver: ( + get: Getter + ) => QueryObserver, + getQueryClient?: (get: Getter) => QueryClient +): Atom> +export function baseAtomWithQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + getOptions: ( + get: Getter + ) => DefaultedInfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + > & { suspense: true; enabled: true }, + getObserver: ( + get: Getter + ) => InfiniteQueryObserver< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + getQueryClient?: (get: Getter) => QueryClient +): Atom>> +export function baseAtomWithQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + getOptions: ( + get: Getter + ) => DefaultedInfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + > & { suspense: false }, + getObserver: ( + get: Getter + ) => InfiniteQueryObserver< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + getQueryClient?: (get: Getter) => QueryClient +): Atom> +export function baseAtomWithQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + getOptions: ( + get: Getter + ) => DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + getObserver: ( + get: Getter + ) => QueryObserver, + getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) +) { + const IN_RENDER = Symbol() + const resetAtom = atom(0) + if (process.env.NODE_ENV !== 'production') { + resetAtom.debugPrivate = true + } + + const observableAtom = atom((get) => { + const observer = getObserver(get) + const source = make>(({ next }) => { + const callback = (result: QueryObserverResult) => { + const notifyResult = () => next(result) + + if ((observer as any)[IN_RENDER]) { + Promise.resolve().then(notifyResult) + } else { + notifyResult() + } + } + + return observer.subscribe(callback) + }) + return pipe(source, toObservable) + }) + if (process.env.NODE_ENV !== 'production') { + observableAtom.debugPrivate = true + } + + const dataAtom = atom((get) => { + const observer = getObserver(get) + const observable = get(observableAtom) + + const currentResult = observer.getCurrentResult() + const resultAtom = atom(currentResult) + if (process.env.NODE_ENV !== 'production') { + resultAtom.debugPrivate = true + } + + resultAtom.onMount = (set) => { + const { unsubscribe } = observable.subscribe((state) => { + set(state) + }) + return () => unsubscribe() + } + + return resultAtom + }) + if (process.env.NODE_ENV !== 'production') { + dataAtom.debugPrivate = true + } + + return atom((get) => { + const observer = getObserver(get) + const options = getOptions(get) + + const client = getQueryClient(get) + + resetAtom.onMount = () => { + return () => { + if (observer.getCurrentResult().isError) { + client.resetQueries({ queryKey: observer.getCurrentQuery().queryKey }) + } + } + } + + get(resetAtom) + const resultAtom = get(dataAtom) + const result = get(resultAtom) + + const optimisticResult = observer.getOptimisticResult(options) + + if (shouldSuspend(options, optimisticResult, false)) { + return observer.fetchOptimistic(options) + } + + if ( + getHasError({ + result, + query: observer.getCurrentQuery(), + throwOnError: options.throwOnError, + }) + ) { + throw result.error + } + + return result + }) +} diff --git a/src/common.ts b/src/common.ts deleted file mode 100644 index 4945cac..0000000 --- a/src/common.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { QueryClient, isCancelledError } from '@tanstack/query-core' -import type { Getter } from 'jotai/vanilla' -import { atom } from 'jotai/vanilla' -import { atomWithObservable } from 'jotai/vanilla/utils' - -export const createAtoms = < - Options, - Result extends { - isSuccess: boolean - isError: boolean - data: any - error: any - }, - Observer extends { - setOptions(options: Options): void - getCurrentResult(): Result - subscribe(callback: (result: Result) => void): () => void - }, - Action, - ActionResult, ->( - getOptions: (get: Getter) => Options, - getQueryClient: (get: Getter) => QueryClient, - createObserver: (client: QueryClient, options: Options) => Observer, - handleAction: ( - action: Action, - observer: Observer, - refresh: () => void - ) => ActionResult -) => { - const observerCacheAtom = atom(() => new WeakMap()) - - if (process.env.NODE_ENV !== 'production') { - observerCacheAtom.debugPrivate = true - } - - const refreshAtom = atom(0) - if (process.env.NODE_ENV !== 'production') { - refreshAtom.debugPrivate = true - } - - // This is for a special property to indicate - // that it is in the render function. - // It's a workaround because we can't use useEffect. - const IN_RENDER = Symbol() - - const observerAtom = atom((get) => { - get(refreshAtom) - const queryClient = getQueryClient(get) - const options = getOptions(get) - const observerCache = get(observerCacheAtom) - let observer = observerCache.get(queryClient) - if (observer) { - ;(observer as any)[IN_RENDER] = true - observer.setOptions(options) - delete (observer as any)[IN_RENDER] - } else { - observer = createObserver(queryClient, options) - observerCache.set(queryClient, observer) - } - return observer - }) - - if (process.env.NODE_ENV !== 'production') { - observerAtom.debugPrivate = true - } - - const baseStatusAtom = atom((get) => { - const observer = get(observerAtom) - const observable = { - subscribe: (arg: { next: (result: Result) => void }) => { - const callback = (result: Result) => { - const notifyResult = () => arg.next(result) - if ((observer as any)[IN_RENDER]) { - Promise.resolve().then(notifyResult) - } else { - notifyResult() - } - } - const unsubscribe = observer.subscribe(callback) - callback(observer.getCurrentResult()) - return { unsubscribe } - }, - } - const resultAtom = atomWithObservable(() => observable, { - initialValue: observer.getCurrentResult(), - }) - - if (process.env.NODE_ENV !== 'production') { - resultAtom.debugPrivate = true - } - - return resultAtom - }) - - if (process.env.NODE_ENV !== 'production') { - baseStatusAtom.debugPrivate = true - } - - const statusAtom = atom( - (get) => { - const resultAtom = get(baseStatusAtom) - return get(resultAtom) - }, - (get, set, action: Action | 'refresh') => { - const observer = get(observerAtom) - const refresh = () => { - const queryClient = getQueryClient(get) - const observerCache = get(observerCacheAtom) - observerCache.delete(queryClient) - set(refreshAtom, (c) => c + 1) - } - if (action === 'refresh') { - refresh() - return undefined as unknown as never - } - return handleAction(action, observer, refresh) - } - ) - statusAtom.onMount = (setAtom) => () => setAtom('refresh') - - const baseDataAtom = atom((get) => { - getOptions(get) // re-create observable when options change - const observer = get(observerAtom) - const observable = { - subscribe: (arg: { next: (result: Result) => void }) => { - const callback = (result: Result) => { - if ( - (result.isSuccess && result.data !== undefined) || - (result.isError && !isCancelledError(result.error)) - ) { - const notifyResult = () => arg.next(result) - if ((observer as any)[IN_RENDER]) { - Promise.resolve().then(notifyResult) - } else { - notifyResult() - } - } - } - const unsubscribe = observer.subscribe(callback) - callback(observer.getCurrentResult()) - return { unsubscribe } - }, - } - const resultAtom = atomWithObservable(() => observable) - - if (process.env.NODE_ENV !== 'production') { - resultAtom.debugPrivate = true - } - - return resultAtom - }) - - if (process.env.NODE_ENV !== 'production') { - baseDataAtom.debugPrivate = true - } - - const returnResultData = (result: Result) => { - if (result.error) { - throw result.error - } - return result.data - } - - const dataAtom = atom( - (get) => { - const resultAtom = get(baseDataAtom) - const result = get(resultAtom) - if (result instanceof Promise) { - return result.then(returnResultData) - } - return returnResultData(result) - }, - (_get, set, action: Action) => set(statusAtom, action) - ) - - return [dataAtom, statusAtom] as const -} - -export const createAsyncAtoms = < - Options, - Result extends { - isSuccess: boolean - isError: boolean - data: any - error: any - }, - Observer extends { - setOptions(options: Options): void - getCurrentResult(): Result - subscribe(callback: (result: Result) => void): () => void - }, - Action, - ActionResult, ->( - getOptions: (get: Getter) => Promise, - getQueryClient: (get: Getter) => QueryClient, - createObserver: (client: QueryClient, options: Options) => Observer, - handleAction: ( - action: Action, - observer: Observer, - refresh: () => void - ) => Promise -) => { - const observerCacheAtom = atom(() => new WeakMap()) - - if (process.env.NODE_ENV !== 'production') { - observerCacheAtom.debugPrivate = true - } - - const refreshAtom = atom(0) - if (process.env.NODE_ENV !== 'production') { - refreshAtom.debugPrivate = true - } - - // This is for a special property to indicate - // that it is in the render function. - // It's a workaround because we can't use useEffect. - const IN_RENDER = Symbol() - - const observerAtom = atom(async (get) => { - get(refreshAtom) - const options = await getOptions(get) - const queryClient = getQueryClient(get) - const observerCache = get(observerCacheAtom) - let observer = observerCache.get(queryClient) - if (observer) { - ;(observer as any)[IN_RENDER] = true - observer.setOptions(options) - delete (observer as any)[IN_RENDER] - } else { - observer = createObserver(queryClient, options) - observerCache.set(queryClient, observer) - } - return observer - }) - - if (process.env.NODE_ENV !== 'production') { - observerAtom.debugPrivate = true - } - - const baseStatusAtom = atom(async (get) => { - const observer = await get(observerAtom) - const observable = { - subscribe: (arg: { next: (result: Result) => void }) => { - const callback = (result: Result) => { - const notifyResult = () => arg.next(result) - if ((observer as any)[IN_RENDER]) { - Promise.resolve().then(notifyResult) - } else { - notifyResult() - } - } - const unsubscribe = observer.subscribe(callback) - callback(observer.getCurrentResult()) - return { unsubscribe } - }, - } - const resultAtom = atomWithObservable(() => observable, { - initialValue: observer.getCurrentResult(), - }) - - if (process.env.NODE_ENV !== 'production') { - resultAtom.debugPrivate = true - } - - return resultAtom - }) - - if (process.env.NODE_ENV !== 'production') { - baseStatusAtom.debugPrivate = true - } - - const statusAtom = atom( - async (get) => { - const resultAtom = await get(baseStatusAtom) - return get(resultAtom) - }, - async (get, set, action: Action) => { - const observer = await get(observerAtom) - const refresh = () => { - const queryClient = getQueryClient(get) - const observerCache = get(observerCacheAtom) - observerCache.delete(queryClient) - set(refreshAtom, (c) => c + 1) - } - return await handleAction(action, observer, refresh) - } - ) - - const baseDataAtom = atom(async (get) => { - getOptions(get) // re-create observable when options change - const observer = await get(observerAtom) - const observable = { - subscribe: (arg: { next: (result: Result) => void }) => { - const callback = (result: Result) => { - if ( - (result.isSuccess && result.data !== undefined) || - (result.isError && !isCancelledError(result.error)) - ) { - const notifyResult = () => arg.next(result) - if ((observer as any)[IN_RENDER]) { - Promise.resolve().then(notifyResult) - } else { - notifyResult() - } - } - } - const unsubscribe = observer.subscribe(callback) - callback(observer.getCurrentResult()) - return { unsubscribe } - }, - } - const resultAtom = atomWithObservable(() => observable) - - if (process.env.NODE_ENV !== 'production') { - resultAtom.debugPrivate = true - } - - return resultAtom - }) - - if (process.env.NODE_ENV !== 'production') { - baseDataAtom.debugPrivate = true - } - - const returnResultData = (result: Result) => { - if (result.error) { - throw result.error - } - return result.data - } - - const dataAtom = atom( - async (get) => { - const resultAtom = await get(baseDataAtom) - const result = await get(resultAtom) - if (result instanceof Promise) { - return result.then(returnResultData) - } - return await returnResultData(result) - }, - (_get, set, action: Action) => set(statusAtom, action) - ) - - return [dataAtom, statusAtom] as const -} diff --git a/src/index.ts b/src/index.ts index e9b273d..1f09617 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export { queryClientAtom } from './queryClientAtom' -export { atomsWithQuery } from './atomsWithQuery' -export { atomsWithInfiniteQuery } from './atomsWithInfiniteQuery' -export { atomsWithMutation } from './atomsWithMutation' -export { atomsWithQueryAsync } from './atomsWithQueryAsync' +export { atomWithQuery } from './atomWithQuery' +export { atomWithSuspenseQuery } from './atomWithSuspenseQuery' +export { atomWithInfiniteQuery } from './atomWithInfiniteQuery' +export { atomWithMutation } from './atomWithMutation' +export { atomWithSuspenseInfiniteQuery } from './atomWithSuspenseInfiniteQuery' +export { atomWithMutationState } from './atomWithMutationState' diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..49bf13b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,56 @@ +import type { + DefaultedQueryObserverOptions, + Query, + QueryKey, + QueryObserverResult, + ThrowOnError, +} from '@tanstack/query-core' + +export const shouldSuspend = ( + defaultedOptions: + | DefaultedQueryObserverOptions + | undefined, + result: QueryObserverResult, + isRestoring: boolean +) => defaultedOptions?.suspense && willFetch(result, isRestoring) + +export const willFetch = ( + result: QueryObserverResult, + isRestoring: boolean +) => result.isPending && !isRestoring + +export const getHasError = < + TData, + TError, + TQueryFnData, + TQueryData, + TQueryKey extends QueryKey, +>({ + result, + throwOnError, + query, +}: { + result: QueryObserverResult + throwOnError: + | ThrowOnError + | undefined + query: Query +}) => { + return ( + result.isError && + !result.isFetching && + shouldThrowError(throwOnError, [result.error, query]) + ) +} + +export function shouldThrowError boolean>( + throwOnError: boolean | T | undefined, + params: Parameters +): boolean { + // Allow useErrorBoundary function to override throwing behavior on a per-error basis + if (typeof throwOnError === 'function') { + return throwOnError(...params) + } + + return !!throwOnError +} diff --git a/tsconfig.json b/tsconfig.json index 3dd8b60..78e5234 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,6 @@ "paths": { "jotai-tanstack-query": ["./src"] } - } + }, + "exclude": ["node_modules", "dist", "webpack.config.js"] }