diff --git a/__tests__/01_basic_spec.tsx b/__tests__/01_basic_spec.tsx index 19b034a..3cd0034 100644 --- a/__tests__/01_basic_spec.tsx +++ b/__tests__/01_basic_spec.tsx @@ -1,17 +1,15 @@ import { - atomsWithInfiniteQuery, - atomsWithMutation, - atomsWithQuery, - atomsWithQueryAsync, + atomWithInfiniteQuery, + atomWithMutation, + atomWithQuery, 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() }) }) 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..70badb4 100644 --- a/__tests__/atomWithMutation.tsx +++ b/__tests__/atomWithMutation_spec.tsx @@ -1,21 +1,20 @@ 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) => { + console.log('mutation triggered') + await new Promise((r) => { + resolve = r + }) + return a + }, + })) function App() { const [mount, setMount] = useState(true) @@ -29,11 +28,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 +44,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__/atomWithQuery_spec.tsx b/__tests__/atomWithQuery_spec.tsx index 221905d..cc5a06a 100644 --- a/__tests__/atomWithQuery_spec.tsx +++ b/__tests__/atomWithQuery_spec.tsx @@ -28,9 +28,9 @@ it('query basic test', async () => { })) const Counter = () => { const [countData] = useAtom(countAtom) - const { data, isLoading, isError } = countData + const { data, isPending, isError } = countData - if (isLoading) { + if (isPending) { return <>loading } @@ -75,9 +75,9 @@ it('query refetch', async () => { }, })) const Counter = () => { - const [{ data, isLoading, isError, refetch }] = useAtom(countAtom) + const [{ data, isPending, isError, refetch }] = useAtom(countAtom) - if (isLoading) { + if (isPending) { return <>loading } @@ -129,9 +129,9 @@ it('query no-loading with keepPreviousData', async () => { })) const Counter = () => { const [countData] = useAtom(countAtom) - const { data, isLoading, isError } = countData + const { data, isPending, isError } = countData - if (isLoading) { + if (isPending) { return <>loading } @@ -195,14 +195,14 @@ it('query with enabled', async () => { const Slug = () => { const [slugQueryData] = useAtom(slugQueryAtom) - const { data, isLoading, isError, status, fetchStatus } = slugQueryData + const { data, isPending, isError, status, fetchStatus } = slugQueryData //ref: https://tanstack.com/query/v4/docs/react/guides/dependent-queries - if (status === 'loading' && fetchStatus === 'idle') { + if (status === 'pending' && fetchStatus === 'idle') { return
not enabled
} - if (isLoading) { + if (isPending) { return <>loading } @@ -267,13 +267,13 @@ it('query with enabled 2', async () => { const Slug = () => { const [slugQueryAtomData] = useAtom(slugQueryAtom) - const { data, isError, isLoading, status, fetchStatus } = slugQueryAtomData + const { data, isError, isPending, status, fetchStatus } = slugQueryAtomData - if (status === 'loading' && fetchStatus === 'idle') { + if (status === 'pending' && fetchStatus === 'idle') { return
not enabled
} - if (isLoading) { + if (isPending) { return <>loading } @@ -351,9 +351,9 @@ it('query with enabled (#500)', async () => { const Counter = () => { const [countData] = useAtom(countAtom) - const { data, isLoading, isError } = countData + const { data, isPending, isError } = countData - if (isLoading) { + if (isPending) { return <>loading } @@ -416,9 +416,9 @@ it('query with initialData test', async () => { })) const Counter = () => { const [countData] = useAtom(countAtom) - const { data, isLoading, isError } = countData + const { data, isPending, isError } = countData - if (isLoading) { + if (isPending) { return <>loading } @@ -464,9 +464,9 @@ it('query dependency test', async () => { const Counter = () => { const [countData] = useAtom(countAtom) - const { data, isLoading, isError } = countData + const { data, isPending, isError } = countData - if (isLoading) { + if (isPending) { return <>loading } @@ -519,9 +519,9 @@ it('query expected QueryCache test', async () => { const Counter = () => { const [countData] = useAtom(countAtom) - const { data, isLoading, isError } = countData + const { data, isPending, isError } = countData - if (isLoading) { + if (isPending) { return <>loading } @@ -538,9 +538,7 @@ it('query expected QueryCache test', async () => { const { findByText } = render( - - - + ) @@ -598,7 +596,7 @@ describe('error handling', () => { const [countData] = useAtom(countAtom) if ('data' in countData) { - if (countData.isLoading) { + if (countData.isPending) { return <>loading } @@ -642,9 +640,9 @@ describe('error handling', () => { const Counter = () => { const [countData] = useAtom(countAtom) - const { data, isLoading, refetch } = countData + const { data, isPending, refetch } = countData - if (isLoading) { + if (isPending) { return <>loading } @@ -715,9 +713,9 @@ it('renews the result when the query changes and a non stale cache is available' const Counter = () => { const setCurrentCount = useSetAtom(currentCountAtom) const [countData] = useAtom(countAtom) - const { data, isLoading, isError } = countData + const { data, isPending, isError } = countData - if (isLoading) { + if (isPending) { return <>loading } diff --git a/__tests__/atomWithSuspenseQuery_spec.tsx b/__tests__/atomWithSuspenseQuery_spec.tsx index 11c8c43..be2883f 100644 --- a/__tests__/atomWithSuspenseQuery_spec.tsx +++ b/__tests__/atomWithSuspenseQuery_spec.tsx @@ -1,18 +1,8 @@ -import React, { - Component, - ReactNode, - StrictMode, - Suspense, - useState, -} from 'react' +import React, { Component, ReactNode, StrictMode, Suspense } from 'react' import { QueryClient } from '@tanstack/query-core' import { fireEvent, render } from '@testing-library/react' -import { Getter, atom, useAtom, useSetAtom } from 'jotai' -import { - QueryErrorResetBoundary, - atomWithQuery, - atomWithSuspenseQuery, -} from '../src' +import { atom, useAtom, useSetAtom } from 'jotai' +import { atomWithSuspenseQuery } from '../src' beforeEach(() => { jest.useFakeTimers() }) @@ -21,131 +11,129 @@ afterEach(() => { jest.useRealTimers() }) -// 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 [countData] = useAtom(countAtom) -// console.log(JSON.stringify({ countData }, null, 2)) -// const { data } = countData -// 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 -// }) +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', 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 })) @@ -192,7 +180,206 @@ describe('intialData test', () => { 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', () => { + 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 = 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( + + + + + + + + ) + + await findByText('loading') + resolve() + await findByText('errored') + }) +}) + +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') +}) 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__/atomsWithQuery_spec.tsx b/__tests__/atomsWithQuery_spec.tsx deleted file mode 100644 index 82ba89b..0000000 --- a/__tests__/atomsWithQuery_spec.tsx +++ /dev/null @@ -1,811 +0,0 @@ -import React, { Component, StrictMode, Suspense, useState } from 'react' -import type { ReactNode } 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' - -beforeEach(() => { - jest.useFakeTimers() -}) -afterEach(() => { - jest.runAllTimers() - jest.useRealTimers() -}) - -it('query basic test', async () => { - let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['count1'], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - return { response: { count: 0 } } - }, - })) - const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) - return ( - <> -
count: {count}
- - ) - } - - const { findByText } = render( - - - - - - ) - - await findByText('loading') - resolve() - await findByText('count: 0') -}) - -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}
- - ) - } - - const { findByText } = render( - - - - - - ) - - await findByText('loading') - resolve() - 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( - - - - - - ) - - await findByText('loading') - resolve() - await findByText('count: 0') - expect(mockFetch).toBeCalledTimes(1) - - fireEvent.click(getByText('refetch')) - await findByText('loading') - resolve() - await findByText('count: 1') - expect(mockFetch).toBeCalledTimes(2) -}) - -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 }, - }, - ] = useAtom(derivedAtom) - return ( - <> -
count: {count}
- - ) - } - const RefreshButton = () => { - const [, dispatch] = useAtom(dispatchAtom) - return ( - - ) - } - - const { findByText, getByText } = 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') -}) - -it('query loading 2', async () => { - let count = 0 - const mockFetch = jest.fn((response) => ({ response })) - let resolve = () => {} - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['count5'], - 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( - - - - - - ) - - 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') -}) - -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}
- - ) - } - const RefreshButton = () => { - const [data, setData] = useAtom(dataAtom) - return - } - - const { findByText, getByText } = render( - - - - - - - ) - - await findByText('loading') - resolve() - await findByText('count: 0') - - fireEvent.click(getByText('refetch')) - await expect(() => findByText('loading')).rejects.toThrow() - resolve() - await findByText('count: 1') - - fireEvent.click(getByText('refetch')) - await expect(() => findByText('loading')).rejects.toThrow() - resolve() - await findByText('count: 2') -}) - -it('query with enabled', async () => { - const slugAtom = atom(null) - const mockFetch = jest.fn((response) => ({ response })) - let resolve = () => {} - const [, slugQueryAtom] = atomsWithQuery((get) => { - const slug = get(slugAtom) - return { - enabled: !!slug, - queryKey: ['disabled_until_value', slug], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - return mockFetch({ slug: `hello-${slug}` }) - }, - } - }) - - const Slug = () => { - const [{ data }] = useAtom(slugQueryAtom) - if (!data?.response?.slug) return
not enabled
- return
slug: {data?.response?.slug}
- } - - const Parent = () => { - const [, setSlug] = useAtom(slugAtom) - return ( -
- - -
- ) - } - - const { getByText, findByText } = render( - - - - - - ) - - await findByText('not enabled') - expect(mockFetch).toHaveBeenCalledTimes(0) - - fireEvent.click(getByText('set slug')) - // 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 enabledAtom = atom(true) - const slugAtom = atom('first') - - const [, slugQueryAtom] = atomsWithQuery((get) => { - const slug = get(slugAtom) - const isEnabled = get(enabledAtom) - return { - enabled: isEnabled, - 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 Parent = () => { - const [, setSlug] = useAtom(slugAtom) - const [, setEnabled] = useAtom(enabledAtom) - return ( -
- - - - -
- ) - } - - const { getByText, findByText } = render( - - - - - - ) - - jest.runOnlyPendingTimers() - await findByText('slug: hello-first') - expect(mockFetch).toHaveBeenCalledTimes(1) - - fireEvent.click(getByText('set disabled')) - fireEvent.click(getByText('set slug')) - - await findByText('slug: hello-first') - expect(mockFetch).toHaveBeenCalledTimes(1) - - fireEvent.click(getByText('set enabled')) - jest.runOnlyPendingTimers() - await findByText('slug: hello-world') - expect(mockFetch).toHaveBeenCalledTimes(2) -}) - -it('query with enabled (#500)', async () => { - const enabledAtom = atom(true) - let resolve = () => {} - const [countAtom] = atomsWithQuery((get) => { - const enabled = get(enabledAtom) - return { - enabled, - queryKey: ['count_500_issue'], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - return { response: { count: 1 } } - }, - } - }) - - const Counter = () => { - const [value] = useAtom(countAtom) - if (!value) return null - const { - response: { count }, - } = value - return
count: {count}
- } - - const Parent = () => { - const [showChildren, setShowChildren] = useState(true) - const [, setEnabled] = useAtom(enabledAtom) - return ( -
- - {showChildren ? :
hidden
} -
- ) - } - - const { getByText, findByText } = render( - - - - - - ) - - await findByText('loading') - resolve() - await findByText('count: 1') - - fireEvent.click(getByText('toggle')) - resolve() - await findByText('hidden') - - fireEvent.click(getByText('toggle')) - resolve() - await findByText('count: 1') -}) - -it('query with initialData test', async () => { - const mockFetch = jest.fn((response) => ({ response })) - - const [countAtom] = atomsWithQuery(() => ({ - queryKey: ['initialData_count1'], - queryFn: async () => { - return mockFetch({ count: 10 }) - }, - initialData: { response: { count: 0 } }, - })) - const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) - return ( - <> -
count: {count}
- - ) - } - - const { findByText } = render( - - - - ) - - // NOTE: the atom never suspends - await findByText('count: 0') - await findByText('count: 10') - expect(mockFetch).toHaveBeenCalledTimes(1) -}) - -it('query dependency test', async () => { - const baseCountAtom = atom(0) - const incrementAtom = atom(null, (_get, set) => - set(baseCountAtom, (c) => c + 1) - ) - let resolve = () => {} - const [countAtom] = atomsWithQuery((get) => ({ - queryKey: ['count_with_dependency', get(baseCountAtom)], - queryFn: async () => { - await new Promise((r) => (resolve = r)) - return { response: { count: get(baseCountAtom) } } - }, - })) - - const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) - return ( - <> -
count: {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') -}) - -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] = atomsWithQuery(() => ({ - queryKey: ['error test', 'count1'], - retry: false, - queryFn: async (): Promise<{ response: { count: number } }> => { - await new Promise((r) => (resolve = r)) - throw new Error('fetch error') - }, - })) - const Counter = () => { - const [ - { - response: { count }, - }, - ] = useAtom(countAtom) - return ( - <> -
count: {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] = atomsWithQuery(() => ({ - 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 [ - { - response: { count }, - }, - dispatch, - ] = useAtom(countAtom) - const refetch = () => dispatch({ type: 'refetch', force: true }) - return ( - <> -
count: {count}
- - - ) - } - - const App = () => { - const dispatch = useSetAtom(countAtom) - const retry = () => { - dispatch({ type: 'refetch', force: true }) - } - 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')) - await findByText('loading') - resolve() - await findByText('errored') - - fireEvent.click(getByText('retry')) - await findByText('loading') - resolve() - await findByText('count: 3') - }) -}) - -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 -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] = atomsWithQuery( - (get) => { - const currentCount = get(currentCountAtom) - return { - queryKey: [currentCount], - queryFn: () => currentCount, - } - }, - () => queryClient - ) - - const Counter = () => { - const setCurrentCount = useSetAtom(currentCountAtom) - const [{ data: count }] = useAtom(countAtom) - return ( - <> - -
count: {count}
- - ) - } - - const { findByText } = render( - - - - ) - - await findByText('count: 1') - fireEvent.click(await findByText('Set count to 2')) - await findByText('count: 2') -}) 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/package.json b/examples/01_typescript/package.json deleted file mode 100644 index 4d5bbef..0000000 --- a/examples/01_typescript/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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/01_typescript/public/index.html b/examples/01_typescript/public/index.html deleted file mode 100644 index a4804d6..0000000 --- a/examples/01_typescript/public/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - jotai-tanstack-query example - - -
- - diff --git a/examples/01_typescript/src/App.tsx b/examples/01_typescript/src/App.tsx deleted file mode 100644 index d30f3a3..0000000 --- a/examples/01_typescript/src/App.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { Suspense } from 'react' -import { useAtom } from 'jotai/react' -import { atom } from 'jotai/vanilla' -import { atomsWithQuery } from 'jotai-tanstack-query' - -const idAtom = atom(1) - -const [userAtom] = atomsWithQuery((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] = useAtom(userAtom) - return
{JSON.stringify(data)}
-} - -const Controls = () => { - const [id, setId] = useAtom(idAtom) - return ( -
- ID: {id}{' '} - {' '} - -
- ) -} - -const App = () => ( - <> - - - - - -) - -export default App diff --git a/examples/01_typescript/src/index.tsx b/examples/01_typescript/src/index.tsx deleted file mode 100644 index 98efb8a..0000000 --- a/examples/01_typescript/src/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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/examples/02_refetch/package.json b/examples/02_refetch/package.json deleted file mode 100644 index 4d5bbef..0000000 --- a/examples/02_refetch/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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/02_refetch/public/index.html b/examples/02_refetch/public/index.html deleted file mode 100644 index a4804d6..0000000 --- a/examples/02_refetch/public/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - jotai-tanstack-query example - - -
- - diff --git a/examples/02_refetch/src/App.tsx b/examples/02_refetch/src/App.tsx deleted file mode 100644 index aac6307..0000000 --- a/examples/02_refetch/src/App.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { Suspense } from 'react' -import { useAtom, useSetAtom } from 'jotai/react' -import { atom } from 'jotai/vanilla' -import { atomsWithQuery } 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) => ({ - queryKey: ['users', get(idAtom)], - queryFn: async ({ queryKey: [, id] }) => { - const res = await fetch(`https://reqres.in/api/users/${id}`) - return res.json() - }, -})) - -const UserData = () => { - const [{ data }, dispatch] = useAtom(userAtom) - return ( -
-
    -
  • ID: {data.id}
  • -
  • First Name: {data.first_name}
  • -
  • Last Name: {data.last_name}
  • -
- -
- ) -} - -const Controls = () => { - const [id, setId] = useAtom(idAtom) - return ( -
- ID: {id}{' '} - {' '} - -
- ) -} - -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}
- -
- ) -} - -const App = () => ( - - - - - - -) - -export default App diff --git a/examples/02_refetch/src/index.tsx b/examples/02_refetch/src/index.tsx deleted file mode 100644 index 98efb8a..0000000 --- a/examples/02_refetch/src/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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/examples/03_infinite/package.json b/examples/03_infinite/package.json deleted file mode 100644 index 4d5bbef..0000000 --- a/examples/03_infinite/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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/03_infinite/public/index.html b/examples/03_infinite/public/index.html deleted file mode 100644 index a4804d6..0000000 --- a/examples/03_infinite/public/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - jotai-tanstack-query example - - -
- - diff --git a/examples/03_infinite/src/App.tsx b/examples/03_infinite/src/App.tsx deleted file mode 100644 index e92e197..0000000 --- a/examples/03_infinite/src/App.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { Suspense } from 'react' -import { useAtom } from 'jotai/react' -import { atomsWithInfiniteQuery } from 'jotai-tanstack-query' - -const [postsAtom] = atomsWithInfiniteQuery(() => ({ - 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, dispatch] = useAtom(postsAtom) - return ( -
- -
    - {data.pages.map((item) => ( -
  • {item.title}
  • - ))} -
-
- ) -} - -const App = () => ( - <> - - - - -) - -export default App diff --git a/examples/03_infinite/src/index.tsx b/examples/03_infinite/src/index.tsx deleted file mode 100644 index 98efb8a..0000000 --- a/examples/03_infinite/src/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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/examples/04_mutation/package.json b/examples/04_mutation/package.json deleted file mode 100644 index 4d5bbef..0000000 --- a/examples/04_mutation/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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/04_mutation/public/index.html b/examples/04_mutation/public/index.html deleted file mode 100644 index a4804d6..0000000 --- a/examples/04_mutation/public/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - jotai-tanstack-query example - - -
- - diff --git a/examples/04_mutation/src/App.tsx b/examples/04_mutation/src/App.tsx deleted file mode 100644 index 86e673a..0000000 --- a/examples/04_mutation/src/App.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' -import { useAtom } from 'jotai/react' -import { atomsWithMutation } from 'jotai-tanstack-query' - -const [, statusAtom] = atomsWithMutation(() => ({ - mutationKey: ['posts'], - mutationFn: async ({ title }: { title: string }) => { - const res = await fetch(`https://jsonplaceholder.typicode.com/posts`, { - method: 'POST', - body: JSON.stringify({ - title, - body: 'body', - userId: 1, - }), - headers: { - 'Content-type': 'application/json; charset=UTF-8', - }, - }) - const data = await res.json() - return data - }, -})) - -const Posts = () => { - const [status, dispatch] = useAtom(statusAtom) - return ( -
- -
{JSON.stringify(status, null, 2)}
-
- ) -} - -const App = () => ( - <> - - -) - -export default App diff --git a/examples/04_mutation/src/index.tsx b/examples/04_mutation/src/index.tsx deleted file mode 100644 index 98efb8a..0000000 --- a/examples/04_mutation/src/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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/examples/05_async/package.json b/examples/05_async/package.json deleted file mode 100644 index 4d5bbef..0000000 --- a/examples/05_async/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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/05_async/public/index.html b/examples/05_async/public/index.html deleted file mode 100644 index a4804d6..0000000 --- a/examples/05_async/public/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - jotai-tanstack-query example - - -
- - 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/src/index.tsx b/examples/05_async/src/index.tsx deleted file mode 100644 index 98efb8a..0000000 --- a/examples/05_async/src/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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 d724a4a..8ef8427 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "postcompile": "cp dist/index.modern.mjs dist/index.modern.js && cp dist/index.modern.mjs.map dist/index.modern.js.map", "test": "run-s eslint tsc-test jest", "eslint": "eslint --ext .js,.ts,.tsx .", - "jest": "jest __tests__/atomWithSuspenseQuery_spec.tsx --watch", + "jest": "jest __tests__/atomWithMutation_spec.tsx --watch", "tsc-test": "tsc --project . --noEmit", "examples:01_typescript": "DIR=01_typescript EXT=tsx webpack serve", "examples:02_refetch": "DIR=02_refetch EXT=tsx webpack serve", @@ -53,7 +53,7 @@ ], "license": "MIT", "devDependencies": { - "@tanstack/query-core": "^4.35.2", + "@tanstack/query-core": "^5.4.3", "@testing-library/react": "^14.0.0", "@types/jest": "^29.5.4", "@types/node": "^20.5.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7974bce..bcf5af0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: devDependencies: '@tanstack/query-core': - specifier: ^4.35.2 - version: 4.35.2 + specifier: ^5.4.3 + version: 5.4.3 '@testing-library/react': specifier: ^14.0.0 version: 14.0.0(react-dom@18.2.0)(react@18.2.0) @@ -1888,8 +1888,8 @@ packages: string.prototype.matchall: 4.0.9 dev: true - /@tanstack/query-core@4.35.2: - resolution: {integrity: sha512-IJYT+VVx0SGe3QWqL6XUgzEY1re3szCdGMSGD1VRdEej6Uq3O+qELR2Ypw1/a4bUYKXiDUpK4BAcBynWb6nbkQ==} + /@tanstack/query-core@5.4.3: + resolution: {integrity: sha512-fnI9ORjcuLGm1sNrKatKIosRQUpuqcD4SV7RqRSVmj8JSicX2aoMyKryHEBpVQvf6N4PaBVgBxQomjsbsGPssQ==} dev: true /@testing-library/dom@9.3.1: diff --git a/src/atomWithInfiniteQuery.ts b/src/atomWithInfiniteQuery.ts new file mode 100644 index 0000000..504aa4f --- /dev/null +++ b/src/atomWithInfiniteQuery.ts @@ -0,0 +1,129 @@ +import { InfiniteQueryObserver, QueryClient } from '@tanstack/query-core' +import type { + InfiniteQueryObserverOptions, + InfiniteQueryObserverResult, + QueryKey, +} from '@tanstack/query-core' +import { type Getter, atom } from 'jotai/vanilla' +import { make, pipe, toObservable } from 'wonka' +import { queryClientAtom } from './queryClientAtom' +import { getHasError } from './utils' +export function atomWithInfiniteQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + getOptions: ( + get: Getter + ) => InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) +) { + const IN_RENDER = Symbol() + + const queryClientAtom = atom(getQueryClient) + const optionsAtom = atom((get) => { + return getOptions(get) + }) + + const observerCacheAtom = atom( + () => + new WeakMap< + QueryClient, + InfiniteQueryObserver< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > + >() + ) + + const observerAtom = atom((get) => { + const options = get(optionsAtom) + const client = get(queryClientAtom) + 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 + }) + + const observableAtom = atom((get) => { + const observer = get(observerAtom) + const source = make>( + ({ next }) => { + const callback = ( + result: InfiniteQueryObserverResult + ) => { + const notifyResult = () => next(result) + + if ((observer as any)[IN_RENDER]) { + Promise.resolve().then(notifyResult) + } else { + notifyResult() + } + } + + const unsubscribe = observer.subscribe(callback) + return () => unsubscribe() + } + ) + 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() + } + + return resultAtom + }) + + return atom((get) => { + const options = get(optionsAtom) + const observer = get(observerAtom) + const resultAtom = get(dataAtom) + const result = get(resultAtom) + + if ( + getHasError({ + query: observer.getCurrentQuery(), + result, + throwOnError: options.throwOnError, + }) + ) { + throw result.error + } + + return result + }) +} diff --git a/src/atomWithMutation.ts b/src/atomWithMutation.ts new file mode 100644 index 0000000..c421b9e --- /dev/null +++ b/src/atomWithMutation.ts @@ -0,0 +1,135 @@ +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 queryClientAtom = atom(getQueryClient) + + const optionsAtom = atom((get) => { + const client = get(queryClientAtom) + const options = getOptions(get) + return client.defaultMutationOptions(options) + }) + + const observerCacheAtom = atom( + () => + new WeakMap< + QueryClient, + MutationObserver + >() + ) + + const observerAtom = atom((get) => { + const options = get(optionsAtom) + const client = get(queryClientAtom) + 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 + }) + + 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() + } + } + + const unsubscribe = observer.subscribe(callback) + return () => unsubscribe() + }) + 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 () => { + observer.reset() + unsubscribe() + } + } + + return resultAtom + }) + + const mutateAtom = atom((get) => { + const observer = get(observerAtom) + const mutate = ( + variables: TVariables, + options?: MutationObserverOptions + ) => { + observer.mutate(variables, options).catch(noop) + } + + return mutate + }) + + 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/atomWithQuery.ts b/src/atomWithQuery.ts index 5aa9cb5..a2987a9 100644 --- a/src/atomWithQuery.ts +++ b/src/atomWithQuery.ts @@ -43,24 +43,12 @@ export function atomWithQuery< ) const observerAtom = atom((get) => { - const isReset = get(isResetAtom) - const options = get(optionsAtom) const client = get(queryClientAtom) const observerCache = get(observerCacheAtom) const observer = observerCache.get(client) - if (isReset) { - if (observer) { - observerCache.delete(client) - observer.remove() - } - const newObserver = new QueryObserver(client, options) - observerCache.set(client, newObserver) - return newObserver - } - if (observer) { ;(observer as any)[IN_RENDER] = true observer.setOptions(options) @@ -112,19 +100,10 @@ export function atomWithQuery< }) return atom((get) => { - const options = get(optionsAtom) - const observer = get(observerAtom) const resultAtom = get(dataAtom) const result = get(resultAtom) - const useErrorBoundary = options.useErrorBoundary - - if ( - result.isError && - (typeof useErrorBoundary === 'function' - ? useErrorBoundary(result.error, observer.getCurrentQuery()) - : useErrorBoundary) - ) { + if (result.isError && !result.isFetching) { throw result.error } diff --git a/src/atomWithSuspenseQuery.ts b/src/atomWithSuspenseQuery.ts index 9b0c347..ec31167 100644 --- a/src/atomWithSuspenseQuery.ts +++ b/src/atomWithSuspenseQuery.ts @@ -1,13 +1,13 @@ import { + type DefinedQueryObserverResult, QueryClient, type QueryKey, QueryObserver, type QueryObserverOptions, type QueryObserverResult, - type QueryObserverSuccessResult, } from '@tanstack/query-core' import { Atom, Getter, atom } from 'jotai' -import { filter, fromPromise, make, pipe, toObservable, toPromise } from 'wonka' +import { make, pipe, toObservable } from 'wonka' import { isResetAtom } from './QueryAtomErrorResetBoundary' import { queryClientAtom } from './queryClientAtom' import { shouldSuspend } from './utils' @@ -30,7 +30,10 @@ export const atomWithSuspenseQuery = < TQueryKey > & { initialData?: TInitialData }, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) -) => { +): Atom< + | DefinedQueryObserverResult + | Promise> +> => { const IN_RENDER = Symbol() const queryClientAtom = atom(getQueryClient) @@ -54,7 +57,6 @@ export const atomWithSuspenseQuery = < const observerAtom = atom((get) => { const isReset = get(isResetAtom) - const options = get(optionsAtom) const client = get(queryClientAtom) const observerCache = get(observerCacheAtom) @@ -64,7 +66,7 @@ export const atomWithSuspenseQuery = < if (isReset) { if (observer) { observerCache.delete(client) - observer.remove() + observer.destroy() } const newObserver = new QueryObserver(client, options) observerCache.set(client, newObserver) @@ -101,11 +103,8 @@ export const atomWithSuspenseQuery = < const unsubscribe = observer.subscribe(callback) return () => unsubscribe() }) - return pipe( - source, - filter((state) => !state.isFetching), - toObservable - ) + + return pipe(source, toObservable) }) const dataAtom = atom((get) => { @@ -119,7 +118,7 @@ export const atomWithSuspenseQuery = < const { unsubscribe } = observable.subscribe((state) => { set(state) }) - return () => unsubscribe() + return unsubscribe } return resultAtom @@ -128,13 +127,15 @@ export const atomWithSuspenseQuery = < return atom((get) => { const options = get(optionsAtom) const observer = get(observerAtom) + const optimisticResult = observer.getOptimisticResult(options) - console.log({ defaultedOptions: options }) - const suspend = shouldSuspend(options, optimisticResult, false) - console.log({ suspend }) + + const suspend = options.suspense && optimisticResult.isPending if (suspend) { - return observer.fetchOptimistic(options) + return observer.fetchOptimistic(options).catch((err) => { + throw err + }) as Promise> } const shouldThrowError = @@ -147,6 +148,6 @@ export const atomWithSuspenseQuery = < const resultAtom = get(dataAtom) const result = get(resultAtom) - return result + return result as DefinedQueryObserverResult }) } 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/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 88fc703..73a8ce6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +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 * from './QueryAtomErrorResetBoundary' diff --git a/src/utils.ts b/src/utils.ts index 0d9cca5..3dd07c6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,7 @@ import type { Query, QueryKey, QueryObserverResult, - UseErrorBoundary, + ThrowOnError, } from '@tanstack/query-core' import { QueryErrorResetBoundaryValue } from './QueryAtomErrorResetBoundary' @@ -18,7 +18,7 @@ export const shouldSuspend = ( export const willFetch = ( result: QueryObserverResult, isRestoring: boolean -) => result.isLoading && !isRestoring +) => result.isPending && !isRestoring export const getHasError = < TData, @@ -29,26 +29,24 @@ export const getHasError = < >({ result, errorResetBoundary, - useErrorBoundary, + throwOnError, query, }: { result: QueryObserverResult - errorResetBoundary: QueryErrorResetBoundaryValue - useErrorBoundary: UseErrorBoundary< - TQueryFnData, - TError, - TQueryData, - TQueryKey - > + errorResetBoundary?: QueryErrorResetBoundaryValue + throwOnError: + | ThrowOnError + | undefined query: Query }) => { return ( result.isError && - !errorResetBoundary.isReset() && + !errorResetBoundary?.isReset() && !result.isFetching && - shouldThrowError(useErrorBoundary, [result.error, query]) + shouldThrowError(throwOnError, [result.error, query]) ) } + export function shouldThrowError boolean>( _useErrorBoundary: boolean | T | undefined, params: Parameters