From df339930816a527dca340bdf5ab82359c0fcb5fb Mon Sep 17 00:00:00 2001 From: Daishi Kato Date: Fri, 19 Feb 2021 20:54:46 +0900 Subject: [PATCH] feat: jotai/query (powered by react-query) (#248) * wip: jotai/query with react-query * fix package json * re implement jotai query * possible fix pending hack * fix: observer and pending * fix: copy script * chore: smplify code * chore: fix typo * query basic test * refetch query * refetch query test * typo * refactor atomWithQuery * fix failing test * chore: refactor * chore: simplify test * chore: refactor create pending * better react suspense * chore: precise types * wip: re-implement atomWithQuery * fix: initializing observe atom * fix: add optional peer dependency * update size snapshot * query loading * fix: making a test to fail * update docs * query loading 2 * new impl * new impl test * new typing * fix csb loading problems * new typing and a small fix * rename the type * reset pending on new fetch * some minor fixes * import type only from relative path * update react-query Co-authored-by: M. Bagher Abiat --- .size-snapshot.json | 19 +++ docs/api/query.md | 38 ++++++ docs/introduction/showcase.md | 4 +- package.json | 12 +- rollup.config.js | 2 + src/query.ts | 1 + src/query/atomWithQuery.ts | 151 +++++++++++++++++++++ tests/query/atomWithQuery.test.tsx | 207 +++++++++++++++++++++++++++++ tests/query/fakeFetch.ts | 13 ++ yarn.lock | 23 +++- 10 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 docs/api/query.md create mode 100644 src/query.ts create mode 100644 src/query/atomWithQuery.ts create mode 100644 tests/query/atomWithQuery.test.tsx create mode 100644 tests/query/fakeFetch.ts diff --git a/.size-snapshot.json b/.size-snapshot.json index 30e9a16a8b..c3cf41d841 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -69,6 +69,20 @@ } } }, + "query.module.js": { + "bundled": 3006, + "minified": 1208, + "gzipped": 579, + "treeshaked": { + "rollup": { + "code": 57, + "import_statements": 49 + }, + "webpack": { + "code": 1078 + } + } + }, "index.js": { "bundled": 23931, "minified": 10862, @@ -93,5 +107,10 @@ "bundled": 1028, "minified": 527, "gzipped": 317 + }, + "query.js": { + "bundled": 4729, + "minified": 2085, + "gzipped": 914 } } diff --git a/docs/api/query.md b/docs/api/query.md new file mode 100644 index 0000000000..003888c655 --- /dev/null +++ b/docs/api/query.md @@ -0,0 +1,38 @@ +This doc describes `jotai/query` bundle. + +## Install + +You have to install `react-query` to access this bundle and its functions. + +``` +yarn add react-query +``` + +## atomWithQuery + +`atomWithQuery` creates a new atom with React Query. This function helps you use both atoms features and `useQuery` features in a single atom. + +```js +import { useAtom } from 'jotai' +import { atomWithQuery } from 'jotai/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] = useAtom(userAtom) + return
{JSON.stringify(data)}
+} +``` + +### Examples + +Basic demo: [codesandbox](https://codesandbox.io/s/jotai-query-demo-ij2sd) + +Hackernews: [codesandbox](https://codesandbox.io/s/jotai-query-hacker-news-u4sli) diff --git a/docs/introduction/showcase.md b/docs/introduction/showcase.md index 9ce5d49dfa..82c174c921 100644 --- a/docs/introduction/showcase.md +++ b/docs/introduction/showcase.md @@ -1,7 +1,5 @@ # Showcase -## Official examples - - Text Length example [![Open in CodeSandbox](https://img.shields.io/badge/Open%20in-CodeSandbox-blue?style=flat-square&logo=codesandbox)](https://githubbox.com/pmndrs/jotai/tree/master/examples/text_length) Count the length and show the uppercase of any text. @@ -24,4 +22,4 @@ - Tic Tac Toe game [![Open in CodeSandbox](https://img.shields.io/badge/Open%20in-CodeSandbox-blue?style=flat-square&logo=codesandbox)](https://codesandbox.io/s/jotai-tic-tac-6cg3h) - A game of tic tac toe implemented with jotai. \ No newline at end of file + A game of tic tac toe implemented with jotai. diff --git a/package.json b/package.json index 520f2a3048..2dcae24932 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,11 @@ "import": "./optics.module.js", "require": "./optics.js", "types": "./optics.d.ts" + }, + "./query": { + "import": "./query.module.mjs", + "require": "./query.js", + "types": "./query.d.ts" } }, "files": [ @@ -56,7 +61,7 @@ "test": "jest && jest --setupFiles ./tests/setReactExperimental.ts", "test:dev": "jest --watch --no-coverage", "test:coverage:watch": "jest --watch", - "copy": "shx mv dist/src/* dist && shx rm -rf dist/{src,tests} && shx cp dist/index.d.ts dist/index.module.d.ts && shx cp dist/utils.d.ts dist/utils.module.d.ts && shx cp dist/devtools.d.ts dist/devtools.module.d.ts && shx cp dist/immer.d.ts dist/immer.module.d.ts && shx cp dist/optics.d.ts dist/optics.module.d.ts && downlevel-dts dist dist/ts3.4 && shx cp package.json readme.md LICENSE dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.husky=undefined; this.prettier=undefined; this.jest=undefined; this['lint-staged']=undefined;\"" + "copy": "shx mv dist/src/* dist && shx rm -rf dist/{src,tests} && shx cp dist/index.d.ts dist/index.module.d.ts && shx cp dist/utils.d.ts dist/utils.module.d.ts && shx cp dist/devtools.d.ts dist/devtools.module.d.ts && shx cp dist/immer.d.ts dist/immer.module.d.ts && shx cp dist/optics.d.ts dist/optics.module.d.ts && shx cp dist/query.d.ts dist/query.module.d.ts && downlevel-dts dist dist/ts3.4 && shx cp package.json readme.md LICENSE dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.husky=undefined; this.prettier=undefined; this.jest=undefined; this['lint-staged']=undefined;\"" }, "husky": { "hooks": { @@ -152,6 +157,7 @@ "prettier": "^2.2.1", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-query": "^3.9.8", "rollup": "^2.39.0", "rollup-plugin-size-snapshot": "^0.12.0", "shx": "^0.3.3", @@ -163,6 +169,7 @@ "react": ">=16.8", "react-dom": "*", "react-native": "*", + "react-query": "*", "scheduler": ">=0.19" }, "peerDependenciesMeta": { @@ -177,6 +184,9 @@ }, "react-native": { "optional": true + }, + "react-query": { + "optional": true } } } diff --git a/rollup.config.js b/rollup.config.js index e0876e79f7..9187533fae 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -62,6 +62,7 @@ export default (args) => createCommonJSConfig('src/devtools.ts', 'dist/devtools.js'), createCommonJSConfig('src/immer.ts', 'dist/immer.js'), createCommonJSConfig('src/optics.ts', 'dist/optics.js'), + createCommonJSConfig('src/query.ts', 'dist/query.js'), ] : [ createESMConfig('src/index.ts', 'dist/index.module.js'), @@ -69,4 +70,5 @@ export default (args) => createESMConfig('src/devtools.ts', 'dist/devtools.module.js'), createESMConfig('src/immer.ts', 'dist/immer.module.js'), createESMConfig('src/optics.ts', 'dist/optics.module.js'), + createESMConfig('src/query.ts', 'dist/query.module.js'), ] diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000000..511c0e9948 --- /dev/null +++ b/src/query.ts @@ -0,0 +1 @@ +export { atomWithQuery } from './query/atomWithQuery' diff --git a/src/query/atomWithQuery.ts b/src/query/atomWithQuery.ts new file mode 100644 index 0000000000..dc9bc3ee4d --- /dev/null +++ b/src/query/atomWithQuery.ts @@ -0,0 +1,151 @@ +import { + QueryClient, + QueryKey, + QueryObserver, + QueryObserverOptions, +} from 'react-query' +import { WritableAtom, atom } from 'jotai' +import type { Getter, Setter } from '../core/types' + +type ResultActions = { type: 'refetch' } +type AtomQueryOptions< + TQueryFnData, + TError, + TData, + TQueryData +> = QueryObserverOptions & { + queryKey: QueryKey +} + +const queryClientAtom = atom(null) +const getQueryClient = (get: Getter, set: Setter): QueryClient => { + let queryClient = get(queryClientAtom) + if (queryClient === null) { + queryClient = new QueryClient() + set(queryClientAtom, queryClient) + } + return queryClient +} + +const createPending = () => { + const pending: { + fulfilled: boolean + promise?: Promise + resolve?: (data: T) => void + } = { + fulfilled: false, + } + pending.promise = new Promise((resolve) => { + pending.resolve = (data: T) => { + resolve(data) + pending.fulfilled = true + } + }) + return pending as { + fulfilled: boolean + promise: Promise + resolve: (data: T) => void + } +} + +export function atomWithQuery< + TQueryFnData, + TError, + TData = TQueryFnData, + TQueryData = TQueryFnData +>( + createQuery: + | AtomQueryOptions + | (( + get: Getter + ) => AtomQueryOptions) +): WritableAtom { + const pendingAtom = atom(createPending()) + const dataAtom = atom(null) + const queryAtom = atom< + [ + AtomQueryOptions, + WritableAtom + ], + ResultActions + >( + (get) => { + const options = + typeof createQuery === 'function' ? createQuery(get) : createQuery + const observerAtom = atom( + null, + ( + get, + set, + action: + | { type: 'init'; intializer: (queryClient: QueryClient) => void } + | { type: 'data'; data: TData } + ) => { + if (action.type === 'init') { + const pending = get(pendingAtom) + if (pending.fulfilled) { + set(pendingAtom, createPending()) // new fetch + } + action.intializer(getQueryClient(get, set)) + } else if (action.type === 'data') { + set(dataAtom, action.data) + const pending = get(pendingAtom) + if (!pending.fulfilled) { + pending.resolve(action.data) + } + } + } + ) + observerAtom.onMount = (dispatch) => { + let unsub: (() => void) | undefined | false + const intializer = (queryClient: QueryClient) => { + const observer = new QueryObserver(queryClient, options) + observer.subscribe((result) => { + // TODO error handling + if (result.data !== undefined) { + dispatch({ type: 'data', data: result.data }) + } + }) + if (unsub === false) { + observer.destroy() + } else { + unsub = () => { + observer.destroy() + } + } + } + dispatch({ type: 'init', intializer }) + return () => { + if (unsub) { + unsub() + } + unsub = false + } + } + return [options, observerAtom] + }, + async (get, set, action) => { + if (action.type === 'refetch') { + const [options] = get(queryAtom) + set(pendingAtom, createPending()) // reset pending + getQueryClient(get, set).getQueryCache().find(options.queryKey)?.reset() + await getQueryClient(get, set).refetchQueries(options.queryKey) + } + } + ) + const queryDataAtom = atom( + (get) => { + const [, observerAtom] = get(queryAtom) + get(observerAtom) // use it here + const data = get(dataAtom) + const pending = get(pendingAtom) + if (!pending.fulfilled) { + return pending.promise + } + // we are sure that data is not null + return data as TData + }, + (_get, set, action) => set(queryAtom, action) // delegate action + ) + return queryDataAtom +} diff --git a/tests/query/atomWithQuery.test.tsx b/tests/query/atomWithQuery.test.tsx new file mode 100644 index 0000000000..9c8e99e12f --- /dev/null +++ b/tests/query/atomWithQuery.test.tsx @@ -0,0 +1,207 @@ +import React from 'react' +import { fireEvent, render } from '@testing-library/react' +import { Provider, atom, useAtom } from '../../src/' +import fakeFetch from './fakeFetch' +import { atomWithQuery } from '../../src/query' + +it('query basic test', async () => { + const countAtom = atomWithQuery(() => ({ + queryKey: 'count', + queryFn: async () => { + return await fakeFetch({ count: 0 }) + }, + })) + const Counter: React.FC = () => { + const [ + { + response: { count }, + }, + ] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + ) + } + + const { findByText } = render( + + + + + + ) + + await findByText('loading') + await findByText('count: 0') +}) +it('query basic test with object instead of function', async () => { + const countAtom = atomWithQuery({ + queryKey: 'count', + queryFn: async () => { + return await fakeFetch({ count: 0 }) + }, + }) + const Counter: React.FC = () => { + const [ + { + response: { count }, + }, + ] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + ) + } + + const { findByText } = render( + + + + + + ) + + await findByText('loading') + await findByText('count: 0') +}) +it('query refetch', async () => { + let count = 0 + const mockFetch = jest.fn(fakeFetch) + const countAtom = atomWithQuery(() => ({ + queryKey: 'count', + queryFn: async () => { + const response = await mockFetch({ count }) + count++ + return response + }, + })) + const Counter: React.FC = () => { + const [ + { + response: { count }, + }, + dispatch, + ] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + await findByText('count: 0') + expect(mockFetch).toBeCalledTimes(1) + fireEvent.click(getByText('refetch')) + expect(mockFetch).toBeCalledTimes(2) + await findByText('loading') + await findByText('count: 1') +}) + +it('query loading', async () => { + let count = 0 + const mockFetch = jest.fn(fakeFetch) + const countAtom = atomWithQuery(() => ({ + queryKey: 'count', + queryFn: async () => { + const response = await mockFetch({ count }, false, 1000) + count++ + return response + }, + })) + const derivedAtom = atom((get) => get(countAtom)) + const dispatchAtom = atom(null, (_get, set, action: any) => + set(countAtom, action) + ) + const Counter: React.FC = () => { + const [ + { + response: { count }, + }, + ] = useAtom(derivedAtom) + return ( + <> +
count: {count}
+ + ) + } + const RefreshButton: React.FC = () => { + const [, dispatch] = useAtom(dispatchAtom) + return ( + + ) + } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('loading') + await findByText('count: 0') + fireEvent.click(getByText('refetch')) + await findByText('loading') + await findByText('count: 1') + fireEvent.click(getByText('refetch')) + await findByText('loading') + await findByText('count: 2') +}) + +it('query loading 2', async () => { + let count = 0 + const mockFetch = jest.fn(fakeFetch) + const countAtom = atomWithQuery(() => ({ + queryKey: 'count', + queryFn: async () => { + const response = await mockFetch({ count }, false, 1000) + count++ + return response + }, + })) + + const Counter: React.FC = () => { + const [ + { + response: { count }, + }, + dispatch, + ] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + await findByText('count: 0') + fireEvent.click(getByText('refetch')) + await findByText('loading') + await findByText('count: 1') + fireEvent.click(getByText('refetch')) + await findByText('loading') + await findByText('count: 2') +}) diff --git a/tests/query/fakeFetch.ts b/tests/query/fakeFetch.ts new file mode 100644 index 0000000000..a8e1d25e88 --- /dev/null +++ b/tests/query/fakeFetch.ts @@ -0,0 +1,13 @@ +async function fakeFetch( + response: Response, + error: boolean = false, + time: number = 0 +): Promise<{ response: Response }> { + await new Promise((r) => setTimeout(r, time)) + if (error) { + throw new Error() + } + return { response } +} + +export default fakeFetch diff --git a/yarn.lock b/yarn.lock index bce5a5d4a1..d016a8c281 100644 --- a/yarn.lock +++ b/yarn.lock @@ -827,7 +827,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.13.tgz#0a21452352b02542db0ffb928ac2d3ca7cb6d66d" integrity sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw== @@ -4863,6 +4863,14 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +match-sorter@^6.0.2: + version "6.2.0" + resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.2.0.tgz#e3f29d4733ef835a0baccc7b9d291c07264985e8" + integrity sha512-yhmUTR5q6JP/ssR1L1y083Wp+C+TdR8LhYTxWI4IRgEUr8IXJu2mE6L3SwryCgX95/5J7qZdEg0G091sOxr1FQ== + dependencies: + "@babel/runtime" "^7.12.5" + remove-accents "0.4.2" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -5745,6 +5753,14 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== +react-query@^3.9.8: + version "3.9.8" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.9.8.tgz#b6345af86f81b342eb228b11d2415a7a04ca5ada" + integrity sha512-cO3DdlHFSE/FDeIhrYVfYaUPm7ElmMW/3sp1QaMSP/aiGfsM62a2gRwd34YVA4dBchNk22L9yu2fZ657A6cdvA== + dependencies: + "@babel/runtime" "^7.5.5" + match-sorter "^6.0.2" + react@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" @@ -5903,6 +5919,11 @@ regjsparser@^0.6.4: dependencies: jsesc "~0.5.0" +remove-accents@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" + integrity sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U= + remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"