From 0e6b6b500f82712b04666618814b04d4d239a27f Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Thu, 20 Jun 2024 14:44:33 +0200 Subject: [PATCH] feat: Move /hooks package into /react pkg --- .changeset/modern-pears-burn.md | 7 + .changeset/poor-cherries-promise.md | 7 + README.md | 2 +- docs/core/README.md | 4 +- docs/core/api/useCache.md | 2 +- docs/core/api/useCancelling.md | 4 - docs/core/api/useDebounce.md | 7 +- docs/core/api/useLoading.md | 4 - docs/core/concepts/expiry-policy.md | 2 +- docs/core/getting-started/installation.md | 2 +- docs/core/guides/abort.md | 2 +- docs/core/shared/_useCancelling.mdx | 3 +- docs/core/shared/_useLoading.mdx | 3 +- docs/rest/api/Collection.md | 2 +- docs/rest/api/Query.md | 2 +- docs/rest/guides/abort.md | 2 +- docs/rest/guides/optimistic-updates.md | 2 +- docs/rest/shared/_optimisticTransform.mdx | 2 +- packages/hooks/README.md | 5 + packages/react/README.md | 2 +- .../src/hooks/__tests__/useCancelling.ts | 81 ++++++ .../react/src/hooks/__tests__/useDebounce.ts | 53 ++++ .../react/src/hooks/__tests__/useLoading.tsx | 207 ++++++++++++++++ packages/react/src/hooks/index.ts | 3 + packages/react/src/hooks/useCancelling.ts | 31 +++ packages/react/src/hooks/useDebounce.ts | 35 +++ packages/react/src/hooks/useLoading.ts | 51 ++++ website/sidebars.json | 6 +- .../editor-types/@data-client/react.d.ts | 231 ++++++++++++++++-- 29 files changed, 705 insertions(+), 59 deletions(-) create mode 100644 .changeset/modern-pears-burn.md create mode 100644 .changeset/poor-cherries-promise.md create mode 100644 packages/react/src/hooks/__tests__/useCancelling.ts create mode 100644 packages/react/src/hooks/__tests__/useDebounce.ts create mode 100644 packages/react/src/hooks/__tests__/useLoading.tsx create mode 100644 packages/react/src/hooks/useCancelling.ts create mode 100644 packages/react/src/hooks/useDebounce.ts create mode 100644 packages/react/src/hooks/useLoading.ts diff --git a/.changeset/modern-pears-burn.md b/.changeset/modern-pears-burn.md new file mode 100644 index 000000000000..e93a78fc77db --- /dev/null +++ b/.changeset/modern-pears-burn.md @@ -0,0 +1,7 @@ +--- +'@data-client/react': patch +--- + +Add [useLoading()](https://dataclient.io/docs/api/useLoading), [useDebounce()](https://dataclient.io/docs/api/useDebounce), [useCancelling()](https://dataclient.io/docs/api/useCancelling) + +These are taken from the hooks package. \ No newline at end of file diff --git a/.changeset/poor-cherries-promise.md b/.changeset/poor-cherries-promise.md new file mode 100644 index 000000000000..4f6f009be0e4 --- /dev/null +++ b/.changeset/poor-cherries-promise.md @@ -0,0 +1,7 @@ +--- +'@data-client/hooks': minor +--- + +**This repository has been deprecated and is no longer actively maintained.** + +All hooks moved to [@data-client/react](https://www.npmjs.com/package/@data-client/react) \ No newline at end of file diff --git a/README.md b/README.md index 7dc5a62dbf66..eaa3fbb268a2 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ For [REST](https://dataclient.io/rest), [GraphQL](https://dataclient.io/graphql) ## Installation ```bash -npm install --save @data-client/react @data-client/rest @data-client/test @data-client/hooks +npm install --save @data-client/react @data-client/rest @data-client/test ``` For more details, see [the Installation docs page](https://dataclient.io/docs/getting-started/installation). diff --git a/docs/core/README.md b/docs/core/README.md index da6d9199ac8e..21c111e37a53 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -260,8 +260,7 @@ function ArticleEdit() { [useLoading()](./api/useLoading.md) enhances async functions by tracking their loading and error states. ```tsx -import { useController } from '@data-client/react'; -import { useLoading } from '@data-client/hooks'; +import { useController, useLoading } from '@data-client/react'; function ArticleEdit() { const ctrl = useController(); @@ -279,7 +278,6 @@ React 18 version with [useTransition](https://react.dev/reference/react/useTrans ```tsx import { useTransition } from 'react'; import { useController } from '@data-client/react'; -import { useLoading } from '@data-client/hooks'; function ArticleEdit() { const ctrl = useController(); diff --git a/docs/core/api/useCache.md b/docs/core/api/useCache.md index b3110ba0ee50..a628af724341 100644 --- a/docs/core/api/useCache.md +++ b/docs/core/api/useCache.md @@ -52,7 +52,7 @@ export const UserResource = createResource({ ``` ```tsx title="Unauthed" collapsed -import { useLoading } from '@data-client/hooks'; +import { useLoading } from '@data-client/react'; import { UserResource } from './UserResource'; export default function Unauthed() { diff --git a/docs/core/api/useCancelling.md b/docs/core/api/useCancelling.md index 03cb24bcfc44..e1bf23a75703 100644 --- a/docs/core/api/useCancelling.md +++ b/docs/core/api/useCancelling.md @@ -16,8 +16,6 @@ Builds an Endpoint that cancels fetch everytime parameters change ## Usage - - :::warning Warning @@ -37,5 +35,3 @@ function useCancelling< }, >(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E { ``` - -Part of [@data-client/hooks](https://www.npmjs.com/package/@data-client/hooks) diff --git a/docs/core/api/useDebounce.md b/docs/core/api/useDebounce.md index d18c4dc17127..65a389f891bb 100644 --- a/docs/core/api/useDebounce.md +++ b/docs/core/api/useDebounce.md @@ -15,8 +15,6 @@ Useful to avoid spamming network requests when parameters might change quickly ( ## Usage - - ```ts title="IssueQuery" collapsed @@ -84,8 +82,7 @@ export default React.memo(IssueList) as typeof IssueList; ``` ```tsx title="SearchIssues" {8} -import { useDebounce } from '@data-client/hooks'; -import { AsyncBoundary } from '@data-client/react'; +import { useDebounce, AsyncBoundary } from '@data-client/react'; import IssueList from './IssueList'; export default function SearchIssues() { @@ -118,5 +115,3 @@ function useDebounce( updatable?: boolean, ): T; ``` - -Part of [@data-client/hooks](https://www.npmjs.com/package/@data-client/hooks) diff --git a/docs/core/api/useLoading.md b/docs/core/api/useLoading.md index 2ac7456b3fbc..32e5d9fdd308 100644 --- a/docs/core/api/useLoading.md +++ b/docs/core/api/useLoading.md @@ -20,8 +20,6 @@ Helps track loading state of imperative async functions. ## Usage - - ## Eslint @@ -52,8 +50,6 @@ export default function useLoading< >(func: F, deps: readonly any[] = []): [F, boolean]; ``` -Part of [@data-client/hooks](https://www.npmjs.com/package/@data-client/hooks) - ## Examples ### Github pagination diff --git a/docs/core/concepts/expiry-policy.md b/docs/core/concepts/expiry-policy.md index 2fc48e91fec1..392b6998758e 100644 --- a/docs/core/concepts/expiry-policy.md +++ b/docs/core/concepts/expiry-policy.md @@ -627,7 +627,7 @@ export default function TimePage({ id }) { ``` ```tsx title="ShowTime" -import { useLoading } from '@data-client/hooks'; +import { useLoading } from '@data-client/react'; import { TimedEntity } from './api/lastUpdated'; import TimePage from './TimePage'; diff --git a/docs/core/getting-started/installation.md b/docs/core/getting-started/installation.md index a56b314c5ad7..fbc61e9baaa5 100644 --- a/docs/core/getting-started/installation.md +++ b/docs/core/getting-started/installation.md @@ -14,7 +14,7 @@ import Installation from '../shared/\_installation.mdx'; import StackBlitz from '@site/src/components/StackBlitz'; import Link from '@docusaurus/Link'; - + ## Add provider at top-level component diff --git a/docs/core/guides/abort.md b/docs/core/guides/abort.md index 50b120ca7267..fa1c81fd8eaf 100644 --- a/docs/core/guides/abort.md +++ b/docs/core/guides/abort.md @@ -59,7 +59,7 @@ abort.abort(); Sometimes a user has the opportunity to fill out a field that is used to affect the results of a network call. If this is a text input, they could potentially type quite quickly, thus creating a lot of network requests. -Using [@data-client/hooks](https://www.npmjs.com/package/@data-client/hooks) package with [useCancelling()](/docs/api/useCancelling) will automatically cancel in-flight requests if the parameters +Using [useCancelling()](/docs/api/useCancelling) will automatically cancel in-flight requests if the parameters change before the request is resolved. diff --git a/docs/core/shared/_useCancelling.mdx b/docs/core/shared/_useCancelling.mdx index f6c73ad2f4ce..37a976f1ae73 100644 --- a/docs/core/shared/_useCancelling.mdx +++ b/docs/core/shared/_useCancelling.mdx @@ -21,8 +21,7 @@ export const TodoResource = createResource({ ``` ```tsx title="TodoDetail" {6} -import { useSuspense } from '@data-client/react'; -import { useCancelling } from '@data-client/hooks'; +import { useSuspense, useCancelling } from '@data-client/react'; import { TodoResource } from './api/Todo'; export default function TodoDetail({ id }: { id: number }) { diff --git a/docs/core/shared/_useLoading.mdx b/docs/core/shared/_useLoading.mdx index 3517e7b8d64e..12b3cfb1be1c 100644 --- a/docs/core/shared/_useLoading.mdx +++ b/docs/core/shared/_useLoading.mdx @@ -88,8 +88,7 @@ export default function PostForm({ onSubmit, loading, error }) { ``` ```tsx title="PostCreate" {8} -import { useController } from '@data-client/react'; -import { useLoading } from '@data-client/hooks'; +import { useLoading, useController } from '@data-client/react'; import { PostResource } from './PostResource'; import PostForm from './PostForm'; diff --git a/docs/rest/api/Collection.md b/docs/rest/api/Collection.md index 264aba6395f2..b42703f62a85 100644 --- a/docs/rest/api/Collection.md +++ b/docs/rest/api/Collection.md @@ -327,7 +327,7 @@ export const getPosts = new RestEndpoint({ ``` ```ts title="PostListLayout" collapsed -import { useLoading } from '@data-client/hooks'; +import { useLoading } from '@data-client/react'; export default function PostListLayout({ postsByBob, diff --git a/docs/rest/api/Query.md b/docs/rest/api/Query.md index 2e65a2e306af..a00da45c72d2 100644 --- a/docs/rest/api/Query.md +++ b/docs/rest/api/Query.md @@ -76,7 +76,7 @@ export const getPosts = new RestEndpoint({ ``` ```tsx title="NewPost" collapsed -import { useLoading } from '@data-client/hooks'; +import { useLoading } from '@data-client/react'; import { getPosts } from './getPosts'; export default function NewPost({ user }: { user: string }) { diff --git a/docs/rest/guides/abort.md b/docs/rest/guides/abort.md index 1c3508b95375..7a94f4b9a995 100644 --- a/docs/rest/guides/abort.md +++ b/docs/rest/guides/abort.md @@ -13,7 +13,7 @@ fetches that are no longer considered relevant. This can be hooked into fetch vi Sometimes a user has the opportunity to fill out a field that is used to affect the results of a network call. If this is a text input, they could potentially type quite quickly, thus creating a lot of network requests. -Using [@data-client/hooks](https://www.npmjs.com/package/@data-client/hooks) package with [useCancelling()](/docs/api/useCancelling) will automatically cancel in-flight requests if the parameters +Using [useCancelling()](/docs/api/useCancelling) will automatically cancel in-flight requests if the parameters change before the request is resolved. diff --git a/docs/rest/guides/optimistic-updates.md b/docs/rest/guides/optimistic-updates.md index 33fd1f54d240..9640aaa6cee7 100644 --- a/docs/rest/guides/optimistic-updates.md +++ b/docs/rest/guides/optimistic-updates.md @@ -338,7 +338,7 @@ export const increment = new RestEndpoint({ ``` ```tsx title="CounterPage" collapsed -import { useLoading } from '@data-client/hooks'; +import { useLoading } from '@data-client/react'; import { getCount } from './count'; import { increment } from './increment'; diff --git a/docs/rest/shared/_optimisticTransform.mdx b/docs/rest/shared/_optimisticTransform.mdx index 85b660803241..14a1b9331a78 100644 --- a/docs/rest/shared/_optimisticTransform.mdx +++ b/docs/rest/shared/_optimisticTransform.mdx @@ -60,7 +60,7 @@ export const increment = new RestEndpoint({ ``` ```tsx title="CounterPage" collapsed -import { useLoading } from '@data-client/hooks'; +import { useLoading } from '@data-client/react'; import { getCount } from './count'; import { increment } from './increment'; diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 69a1aa7ebf94..83bb7bc4ec14 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -1,4 +1,5 @@ # Networking Hooks + [![CircleCI](https://circleci.com/gh/reactive/data-client/tree/master.svg?style=shield)](https://circleci.com/gh/reactive/data-client) [![Coverage Status](https://img.shields.io/codecov/c/gh/reactive/data-client/master.svg?style=flat-square)](https://app.codecov.io/gh/reactive/data-client?branch=master) [![npm downloads](https://img.shields.io/npm/dm/@data-client/hooks.svg?style=flat-square)](https://www.npmjs.com/package/@data-client/hooks) @@ -14,6 +15,10 @@ Composable hooks for networking data +## 🚨 Repository Deprecated and Moved! 🚨 + +**This repository has been deprecated and is no longer actively maintained.** All hooks moved to [@data-client/react](https://www.npmjs.com/package/@data-client/react) + ### [useCancelling()](https://dataclient.io/docs/api/useCancelling) [Aborts](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) inflight request if the parameters change. diff --git a/packages/react/README.md b/packages/react/README.md index 4f8784d515e6..6021789fed98 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -24,7 +24,7 @@ For [REST](https://dataclient.io/rest), [GraphQL](https://dataclient.io/graphql) ## Installation ```bash -npm install --save @data-client/react @data-client/rest @data-client/test @data-client/hooks +npm install --save @data-client/react @data-client/rest @data-client/test ``` For more details, see [the Installation docs page](https://dataclient.io/docs/getting-started/installation). diff --git a/packages/react/src/hooks/__tests__/useCancelling.ts b/packages/react/src/hooks/__tests__/useCancelling.ts new file mode 100644 index 000000000000..bbd014c30ad8 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useCancelling.ts @@ -0,0 +1,81 @@ +import { ArticleResource } from '__tests__/new'; +import nock from 'nock'; + +import { renderHook, act } from '../../../../test'; +import useCancelling from '../useCancelling'; + +describe('useCancelling()', () => { + const payload = { + id: '6', + title: 'lala', + }; + const payload2 = { + id: '7', + title: 'second one', + }; + beforeAll(() => { + jest.useFakeTimers({ + legacyFakeTimers: true, + }); + const mynock = nock(/.*/) + .persist() + .defaultReplyHeaders({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Access-Token', + 'Content-Type': 'application/json', + }) + .options(/.*/) + .reply(200); + + mynock + .get(`/article/${payload.id}`) + .delay(2000) + .reply(200, payload) + .get(`/article/${payload2.id}`) + .delay(2000) + .reply(200, payload2); + }); + afterAll(() => { + jest.useRealTimers(); + nock.cleanAll(); + }); + + it('should abort when props change and resolve when kept the same', async () => { + const { result, rerender } = renderHook( + ({ id }: { id: string }) => { + return useCancelling(ArticleResource.get, { id }); + }, + { initialProps: { id: '6' } }, + ); + const firstendpoint = result.current; + const ogPromise = result.current({ id: '6' }); + jest.advanceTimersByTime(10); + act(() => rerender({ id: '7' })); + expect(result.current).not.toBe(firstendpoint); + expect(ogPromise).rejects.toMatchInlineSnapshot(`[AbortError: Aborted]`); + const nextPromise = result.current({ id: '7' }); + jest.advanceTimersByTime(2000); + await expect(nextPromise).resolves.toMatchInlineSnapshot(` + { + "id": "7", + "title": "second one", + } + `); + act(() => rerender({ id: '7' })); + }); + + it('should remain === if params does not change', () => { + const { result, rerender } = renderHook( + ({ id }: { id: string }) => { + return useCancelling(ArticleResource.get, { id }); + }, + { initialProps: { id: '6' } }, + ); + let lastendpoint = result.current; + act(() => rerender({ id: '6' })); + expect(result.current).toBe(lastendpoint); + lastendpoint = result.current; + act(() => rerender({ id: '6' })); + expect(result.current).toBe(lastendpoint); + }); +}); diff --git a/packages/react/src/hooks/__tests__/useDebounce.ts b/packages/react/src/hooks/__tests__/useDebounce.ts new file mode 100644 index 000000000000..7e3c6819c1bb --- /dev/null +++ b/packages/react/src/hooks/__tests__/useDebounce.ts @@ -0,0 +1,53 @@ +import { renderHook, act } from '../../../../test'; +import useDebounce from '../useDebounce'; + +describe('useDebounce()', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it('should not update until delay has passed', () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => { + return useDebounce(value, 100); + }, + { initialProps: { value: 'initial' } }, + ); + expect(result.current).toBe('initial'); + jest.advanceTimersByTime(10); + rerender({ value: 'next' }); + rerender({ value: 'third' }); + expect(result.current).toBe('initial'); + act(() => { + jest.advanceTimersByTime(100); + }); + expect(result.current).toBe('third'); + }); + + it('should never update when updatable is false', () => { + const { result, rerender } = renderHook( + ({ value, updatable }: { value: string; updatable: boolean }) => { + return useDebounce(value, 100, updatable); + }, + { initialProps: { value: 'initial', updatable: false } }, + ); + expect(result.current).toBe('initial'); + jest.advanceTimersByTime(10); + rerender({ value: 'next', updatable: false }); + act(() => { + jest.advanceTimersByTime(100); + }); + expect(result.current).toBe('initial'); + rerender({ value: 'third', updatable: true }); + expect(result.current).toBe('initial'); + jest.advanceTimersByTime(10); + expect(result.current).toBe('initial'); + act(() => { + jest.advanceTimersByTime(100); + }); + expect(result.current).toBe('third'); + }); +}); diff --git a/packages/react/src/hooks/__tests__/useLoading.tsx b/packages/react/src/hooks/__tests__/useLoading.tsx new file mode 100644 index 000000000000..03db34f27d64 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useLoading.tsx @@ -0,0 +1,207 @@ +import { render, waitFor } from '@testing-library/react'; + +import { renderHook, act } from '../../../../test'; +import useLoading from '../useLoading'; + +describe('useLoading()', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it('should not update until delay has passed', async () => { + function fun(value: string) { + return new Promise((resolve, reject) => + setTimeout(() => resolve(value), 1000), + ); + } + let resolved = ''; + let wrongType = 0; + const { result, waitForNextUpdate } = renderHook(() => { + return useLoading(fun); + }); + const wrappedFunc = result.current[0]; + expect(result.current[1]).toBe(false); + act(() => { + wrappedFunc('test string').then(value => { + resolved = value; + // @ts-expect-error + wrongType = value; + }); + }); + expect(result.current[1]).toBe(true); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(result.current[1]).toBe(true); + act(() => { + jest.advanceTimersByTime(600); + }); + await waitForNextUpdate(); + expect(result.current[1]).toBe(false); + expect(resolved).toBe('test string'); + // maintain referential equality + expect(result.current[0]).toBe(wrappedFunc); + }); + + it('should work when resolution happens after unmount', async () => { + function fun(value: string) { + return new Promise((resolve, reject) => + setTimeout(() => resolve(value), 1000), + ); + } + let resolved = ''; + let wrongType = 0; + const { result, unmount } = renderHook(() => { + return useLoading(fun); + }); + const wrappedFunc = result.current[0]; + expect(result.current[1]).toBe(false); + act(() => { + wrappedFunc('test string').then(value => { + resolved = value; + // @ts-expect-error + wrongType = value; + }); + }); + expect(result.current[1]).toBe(true); + act(() => { + jest.advanceTimersByTime(500); + }); + act(() => { + unmount(); + }); + act(() => { + jest.advanceTimersByTime(600); + }); + // since it's unmounted this won't change + expect(result.current[1]).toBe(true); + }); + + it('should call error callback when rejected', async () => { + const error = new Error('ack'); + function fun(value: string) { + return new Promise((resolve, reject) => + setTimeout(() => reject(error), 1000), + ).catch(err => { + rejectedError = err; + throw err; + }); + } + let rejectedError: Error | null = null; + const { result, waitForNextUpdate } = renderHook(() => { + return useLoading(fun); + }); + const wrappedFunc = result.current[0]; + expect(result.current[1]).toBe(false); + act(() => { + wrappedFunc('test string'); + }); + expect(result.current[1]).toBe(true); + act(() => { + jest.advanceTimersByTime(1100); + }); + await waitForNextUpdate(); + expect(result.current[1]).toBe(false); + expect(result.current[2]).toBeDefined(); + expect(rejectedError).toBe(error); + // maintain referential equality + expect(result.current[0]).toBe(wrappedFunc); + }); + + it('should stop loading when error thrown', async () => { + const error = new Error('ack'); + function fun(value: string) { + return new Promise((resolve, reject) => + setTimeout(() => reject(error), 1000), + ).catch(err => { + rejectedError = err; + throw err; + }); + } + let rejectedError: Error | null = null; + const { result, waitForNextUpdate } = renderHook(() => { + return useLoading(fun); + }); + const wrappedFunc = result.current[0]; + expect(result.current[1]).toBe(false); + act(() => { + wrappedFunc('test string'); + }); + expect(result.current[1]).toBe(true); + act(() => { + jest.advanceTimersByTime(1100); + }); + await waitForNextUpdate(); + expect(result.current[1]).toBe(false); + expect(rejectedError).toBe(error); + expect(result.current[2]).toBeDefined(); + expect(result.current[2]).toBe(error); + // maintain referential equality + expect(result.current[0]).toBe(wrappedFunc); + }); + + it('should work in strictmode', async () => { + const error = new Error('ack'); + function fun(value: string) { + return new Promise((resolve, reject) => + setTimeout(() => reject(error), 1000), + ).catch(err => { + rejectedError = err; + throw err; + }); + } + let rejectedError: Error | null = null; + let wrappedFunc: (value: string) => Promise; + const Data = () => { + let loading: boolean; + [wrappedFunc, loading] = useLoading(fun); + return
{loading ? 'loading' : 'loaded'}
; + }; + const tree = ; + const { getByText } = render(tree); + expect(getByText(/loaded/i)).toBeDefined(); + act(() => { + wrappedFunc('test string'); + }); + expect(getByText(/loading/i)).toBeDefined(); + act(() => { + jest.advanceTimersByTime(1100); + }); + await waitFor(() => expect(getByText(/loaded/i)).toBeDefined()); + }); + + it('should maintain referential equality if function does', async () => { + function fun(value: string) { + return new Promise((resolve, reject) => + setTimeout(() => resolve(value), 1000), + ); + } + const { result, rerender } = renderHook(() => { + return useLoading(fun); + }); + const [cb] = result.current; + rerender(); + expect(result.current[0]).toBe(cb); + }); + + it('should maintain referential equality based on deps', async () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => { + return useLoading(() => { + return new Promise((resolve, reject) => + setTimeout(() => resolve(value), 1000), + ); + }, [value]); + }, + { initialProps: { value: 'a' } }, + ); + const [cb] = result.current; + rerender({ value: 'a' }); + expect(result.current[0]).toBe(cb); + rerender({ value: 'b' }); + expect(result.current[0]).not.toBe(cb); + }); +}); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index bc74feb9d923..79fae94510bc 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -7,3 +7,6 @@ export { default as useSubscription } from './useSubscription.js'; export { default as useDLE } from './useDLE.js'; export { default as useController } from './useController.js'; export { default as useLive } from './useLive.js'; +export { default as useDebounce } from './useDebounce.js'; +export { default as useCancelling } from './useCancelling.js'; +export { default as useLoading } from './useLoading.js'; diff --git a/packages/react/src/hooks/useCancelling.ts b/packages/react/src/hooks/useCancelling.ts new file mode 100644 index 000000000000..4a8646e8fa43 --- /dev/null +++ b/packages/react/src/hooks/useCancelling.ts @@ -0,0 +1,31 @@ +import type { EndpointInterface } from '@data-client/normalizr'; +import { useMemo, useRef } from 'react'; + +/** + * Builds an Endpoint that cancels fetch everytime params change + * + * @see https://dataclient.io/docs/api/useCancelling + * @example + ``` + useSuspense(useCancelling(MyEndpoint, { id }), { id }) + ``` + */ +export default function useCancelling< + E extends EndpointInterface & { + extend: (o: { signal?: AbortSignal }) => any; + }, +>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E { + const abortRef = useRef(); + + // send abort signal anytime the params change + // if fetch is already completed signal goes nowhere + const key = args[0] !== null ? endpoint.key(...args) : ''; + return useMemo(() => { + if (abortRef.current) abortRef.current.abort(); + abortRef.current = new AbortController(); + return endpoint.extend({ + signal: abortRef.current.signal, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); +} diff --git a/packages/react/src/hooks/useDebounce.ts b/packages/react/src/hooks/useDebounce.ts new file mode 100644 index 000000000000..8c45915553ae --- /dev/null +++ b/packages/react/src/hooks/useDebounce.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; + +/** + * Keeps value updated after delay time + * + * @see https://dataclient.io/docs/api/useDebounce + * @param value Any immutable value + * @param delay Time in miliseconds to wait til updating the value + * @param updatable Whether to update at all + * @example + ``` + const debouncedFilter = useDebounced(filter, 200); + const list = useSuspense(ListShape, { filter }); + ``` + */ +export default function useDebounce( + value: T, + delay: number, + updatable = true, +) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + if (!updatable) return; + + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + }; + }, [value, delay, updatable]); + + return debouncedValue; +} diff --git a/packages/react/src/hooks/useLoading.ts b/packages/react/src/hooks/useLoading.ts new file mode 100644 index 000000000000..3ed23ee998fb --- /dev/null +++ b/packages/react/src/hooks/useLoading.ts @@ -0,0 +1,51 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; + +/** + * Takes an async function and tracks resolution as a boolean. + * + * @see https://dataclient.io/docs/api/useLoading + * @param func A function returning a promise + * @param deps Deps list sent to useCallback() + * @example + ``` + function Button({ onClick, children, ...props }) { + const [clickHandler, loading] = useLoading(onClick); + return ( + + ); + } + ``` + */ +export default function useLoading Promise>( + func: F, + deps?: readonly any[], +): [F, boolean, Error | undefined] { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + const isMountedRef = useRef(true); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + const depsList = deps || [func]; + const wrappedFunc = useCallback(async (...args: any) => { + setLoading(true); + let ret; + try { + ret = await func(...args); + } catch (e: any) { + setError(e); + } finally { + if (isMountedRef.current) { + setLoading(false); + } + } + return ret; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, depsList); + return [wrappedFunc as any, loading, error]; +} diff --git a/website/sidebars.json b/website/sidebars.json index 9bfb5b9741d4..8dda9b7b26ac 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -192,15 +192,15 @@ }, { "type": "doc", - "id": "api/useDebounce" + "id": "api/useLoading" }, { "type": "doc", - "id": "api/useCancelling" + "id": "api/useDebounce" }, { "type": "doc", - "id": "api/useLoading" + "id": "api/useCancelling" } ] }, diff --git a/website/src/components/Playground/editor-types/@data-client/react.d.ts b/website/src/components/Playground/editor-types/@data-client/react.d.ts index 26c5124fcf2b..da9ab61401a3 100644 --- a/website/src/components/Playground/editor-types/@data-client/react.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react.d.ts @@ -1,5 +1,5 @@ import * as _data_client_core from '@data-client/core'; -import { Manager, State, Controller, EndpointInterface, FetchFunction, Schema, ResolveType, Denormalize, DenormalizeNullable, Queryable, NI, SchemaArgs, NetworkError, UnknownError, ErrorTypes as ErrorTypes$1, __INTERNAL__, createReducer, applyManager } from '@data-client/core'; +import { Manager, State, Controller, EndpointInterface as EndpointInterface$1, FetchFunction as FetchFunction$1, Schema as Schema$1, ResolveType as ResolveType$1, Denormalize as Denormalize$1, DenormalizeNullable as DenormalizeNullable$1, Queryable as Queryable$1, NI, SchemaArgs, NetworkError as NetworkError$1, UnknownError as UnknownError$1, ErrorTypes as ErrorTypes$2, __INTERNAL__, createReducer, applyManager } from '@data-client/core'; export { AbstractInstanceType, ActionTypes, Controller, DataClientDispatch, DefaultConnectionListener, Denormalize, DenormalizeNullable, DevToolsManager, Dispatch, EndpointExtraOptions, EndpointInterface, ErrorTypes, ExpiryStatus, FetchAction, FetchFunction, GenericDispatch, InvalidateAction, LogoutManager, Manager, Middleware, MiddlewareAPI, NetworkError, NetworkManager, Normalize, NormalizeNullable, PK, PollingSubscription, ResetAction, ResolveType, Schema, SetAction, SetResponseAction, State, SubscribeAction, SubscriptionManager, UnknownError, UnsubscribeAction, UpdateFunction, actionTypes } from '@data-client/core'; import * as react_jsx_runtime from 'react/jsx-runtime'; import React, { JSX, Context } from 'react'; @@ -103,8 +103,8 @@ interface Props { * @throws {Promise} If data is not yet available. * @throws {NetworkError} If fetch fails. */ -declare function useSuspense>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType : Denormalize; -declare function useSuspense>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType | undefined : DenormalizeNullable; +declare function useSuspense>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType$1 : Denormalize$1; +declare function useSuspense>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType$1 | undefined : DenormalizeNullable$1; /** * Access a response if it is available. @@ -112,7 +112,7 @@ declare function useSuspense, 'key' | 'schema' | 'invalidIfStale'>>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? E extends (...args: any) => any ? ResolveType | undefined : any : DenormalizeNullable; +declare function useCache, 'key' | 'schema' | 'invalidIfStale'>>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? E extends (...args: any) => any ? ResolveType$1 | undefined : any : DenormalizeNullable$1; /** * Query the store. @@ -120,43 +120,43 @@ declare function useCache(schema: S, ...args: NI>): DenormalizeNullable | undefined; +declare function useQuery(schema: S, ...args: NI>): DenormalizeNullable$1 | undefined; -type ErrorTypes = NetworkError | UnknownError; +type ErrorTypes$1 = NetworkError$1 | UnknownError$1; /** * Get any errors for a given request * @see https://dataclient.io/docs/api/useError */ -declare function useError>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): ErrorTypes | undefined; +declare function useError>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): ErrorTypes$1 | undefined; /** * Request a resource if it is not in cache. * @see https://dataclient.io/docs/api/useFetch */ -declare function useFetch>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ReturnType : Promise>; -declare function useFetch>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ReturnType | undefined : Promise>; +declare function useFetch>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ReturnType : Promise>; +declare function useFetch>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ReturnType | undefined : Promise>; /** * Keeps a resource fresh by subscribing to updates. * @see https://dataclient.io/docs/api/useSubscription */ -declare function useSubscription>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): void; +declare function useSubscription>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): void; -type SchemaReturn = { - data: Denormalize; +type SchemaReturn = { + data: Denormalize$1; loading: false; error: undefined; } | { - data: DenormalizeNullable; + data: DenormalizeNullable$1; loading: true; error: undefined; } | { - data: DenormalizeNullable; + data: DenormalizeNullable$1; loading: false; - error: ErrorTypes$1; + error: ErrorTypes$2; }; type AsyncReturn = { - data: E extends (...args: any) => any ? ResolveType : any; + data: E extends (...args: any) => any ? ResolveType$1 : any; loading: false; error: undefined; } | { @@ -166,17 +166,17 @@ type AsyncReturn = { } | { data: undefined; loading: false; - error: ErrorTypes$1; + error: ErrorTypes$2; }; /** * Use async date with { data, loading, error } (DLE) * @see https://dataclient.io/docs/api/useDLE */ -declare function useDLE>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? AsyncReturn : SchemaReturn; -declare function useDLE>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): { - data: E['schema'] extends undefined | null ? undefined : DenormalizeNullable; +declare function useDLE>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? AsyncReturn : SchemaReturn; +declare function useDLE>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): { + data: E['schema'] extends undefined | null ? undefined : DenormalizeNullable$1; loading: boolean; - error: ErrorTypes$1 | undefined; + error: ErrorTypes$2 | undefined; }; /** @@ -193,8 +193,191 @@ declare function useController(): Controller; * @throws {Promise} If data is not yet available. * @throws {NetworkError} If fetch fails. */ -declare function useLive>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType : Denormalize; -declare function useLive>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType | undefined : DenormalizeNullable; +declare function useLive>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType$1 : Denormalize$1; +declare function useLive>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType$1 | undefined : DenormalizeNullable$1; + +/** + * Keeps value updated after delay time + * + * @see https://dataclient.io/docs/api/useDebounce + * @param value Any immutable value + * @param delay Time in miliseconds to wait til updating the value + * @param updatable Whether to update at all + * @example + ``` + const debouncedFilter = useDebounced(filter, 200); + const list = useSuspense(ListShape, { filter }); + ``` + */ +declare function useDebounce(value: T, delay: number, updatable?: boolean): T; + +type Schema = null | string | { + [K: string]: any; +} | Schema[] | SchemaSimple | Serializable; +interface Queryable { + queryKey(args: readonly any[], queryKey: (...args: any) => any, getEntity: GetEntity, getIndex: GetIndex): {}; +} +type Serializable = (value: any) => T; +interface SchemaSimple { + normalize(input: any, parent: any, key: any, visit: (...args: any) => any, addEntity: (...args: any) => any, visitedEntities: Record, storeEntities: any, args: any[]): any; + denormalize(input: {}, args: readonly any[], unvisit: (input: any, schema: any) => any): T; + queryKey(args: Args, queryKey: (...args: any) => any, getEntity: GetEntity, getIndex: GetIndex): any; +} +interface EntityInterface extends SchemaSimple { + createIfValid(props: any): any; + pk(params: any, parent?: any, key?: string, args?: readonly any[]): string | number | undefined; + readonly key: string; + merge(existing: any, incoming: any): any; + mergeWithStore(existingMeta: any, incomingMeta: any, existing: any, incoming: any): any; + mergeMetaWithStore(existingMeta: any, incomingMeta: any, existing: any, incoming: any): any; + indexes?: any; + schema: Record; + cacheWith?: object; + prototype: T; +} +/** Get Array of entities with map function applied */ +interface GetEntity { + (entityKey: string): { + readonly [pk: string]: any; + } | undefined; + (entityKey: string, pk: string | number): any; +} +/** Get PK using an Entity Index */ +interface GetIndex { + /** getIndex('User', 'username', 'ntucker') */ + (entityKey: string, field: string, value: string): { + readonly [indexKey: string]: string | undefined; + }; +} + +type AbstractInstanceType = T extends new (...args: any) => infer U ? U : T extends { + prototype: infer U; +} ? U : never; +type DenormalizeObject> = { + [K in keyof S]: S[K] extends Schema ? Denormalize : S[K]; +}; +type DenormalizeNullableObject> = { + [K in keyof S]: S[K] extends Schema ? DenormalizeNullable : S[K]; +}; +interface NestedSchemaClass { + schema: Record; + prototype: T; +} +interface RecordClass extends NestedSchemaClass { + fromJS: (...args: any) => AbstractInstanceType; +} +type DenormalizeNullableNestedSchema = keyof S['schema'] extends never ? S['prototype'] : string extends keyof S['schema'] ? S['prototype'] : S['prototype']; +type Denormalize = S extends EntityInterface ? U : S extends RecordClass ? AbstractInstanceType : S extends { + denormalize: (...args: any) => any; +} ? ReturnType : S extends Serializable ? T : S extends Array ? Denormalize[] : S extends { + [K: string]: any; +} ? DenormalizeObject : S; +type DenormalizeNullable = S extends EntityInterface ? DenormalizeNullableNestedSchema | undefined : S extends RecordClass ? DenormalizeNullableNestedSchema : S extends { + _denormalizeNullable: (...args: any) => any; +} ? ReturnType : S extends Serializable ? T : S extends Array ? Denormalize[] | undefined : S extends { + [K: string]: any; +} ? DenormalizeNullableObject : S; + +interface NetworkError extends Error { + status: number; + response?: Response; +} +interface UnknownError extends Error { + status?: unknown; + response?: unknown; +} +type ErrorTypes = NetworkError | UnknownError; + +/** What the function's promise resolves to */ +type ResolveType any> = ReturnType extends Promise ? R : never; + +type ExpiryStatusInterface = 1 | 2 | 3; + +interface SnapshotInterface { + /** + * Gets the (globally referentially stable) response for a given endpoint/args pair from state given. + * @see https://dataclient.io/docs/api/Snapshot#getResponse + */ + getResponse>(endpoint: E, ...args: readonly any[]): { + data: DenormalizeNullable; + expiryStatus: ExpiryStatusInterface; + expiresAt: number; + }; + /** @see https://dataclient.io/docs/api/Snapshot#getError */ + getError: , Args extends readonly [...Parameters]>(endpoint: E, ...args: Args) => ErrorTypes | undefined; + /** + * Retrieved memoized value for any Querable schema + * @see https://dataclient.io/docs/api/Snapshot#get + */ + get(schema: S, ...args: readonly any[]): any; + readonly fetchedAt: number; + readonly abort: Error; +} + +/** Defines a networking endpoint */ +interface EndpointInterface extends EndpointExtraOptions { + (...args: Parameters): ReturnType; + key(...args: Parameters): string; + readonly sideEffect?: M; + readonly schema?: S; +} +interface EndpointExtraOptions { + /** Default data expiry length, will fall back to NetworkManager default if not defined */ + readonly dataExpiryLength?: number; + /** Default error expiry length, will fall back to NetworkManager default if not defined */ + readonly errorExpiryLength?: number; + /** Poll with at least this frequency in miliseconds */ + readonly pollFrequency?: number; + /** Marks cached resources as invalid if they are stale */ + readonly invalidIfStale?: boolean; + /** Enables optimistic updates for this request - uses return value as assumed network response */ + getOptimisticResponse?(snap: SnapshotInterface, ...args: Parameters): ResolveType; + /** Determines whether to throw or fallback to */ + errorPolicy?(error: any): 'hard' | 'soft' | undefined; + /** User-land extra data to send */ + readonly extra?: any; +} + +type FetchFunction = (...args: A) => Promise; + +/** + * Builds an Endpoint that cancels fetch everytime params change + * + * @see https://dataclient.io/docs/api/useCancelling + * @example + ``` + useSuspense(useCancelling(MyEndpoint, { id }), { id }) + ``` + */ +declare function useCancelling any; +}>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E; + +/** + * Takes an async function and tracks resolution as a boolean. + * + * @see https://dataclient.io/docs/api/useLoading + * @param func A function returning a promise + * @param deps Deps list sent to useCallback() + * @example + ``` + function Button({ onClick, children, ...props }) { + const [clickHandler, loading] = useLoading(onClick); + return ( + + ); + } + ``` + */ +declare function useLoading Promise>(func: F, deps?: readonly any[]): [F, boolean, Error | undefined]; declare const StateContext: Context>; declare const ControllerContext: Context>; @@ -232,4 +415,4 @@ declare namespace internal_d { /** Turns a dispatch function into one that resolves once its been commited */ declare function usePromisifiedDispatch>(dispatch: React.Dispatch>, state: React.ReducerState): (action: React.ReducerAction) => Promise; -export { _default as AsyncBoundary, BackupLoading, DataProvider as CacheProvider, ControllerContext, DataProvider, DevToolsPosition, ErrorBoundary, ErrorBoundary as NetworkErrorBoundary, ProviderProps, StateContext, Store, StoreContext, UniversalSuspense, internal_d as __INTERNAL__, getDefaultManagers, useCache, useController, useDLE, useError, useFetch, useLive, usePromisifiedDispatch, useQuery, useSubscription, useSuspense }; +export { _default as AsyncBoundary, BackupLoading, DataProvider as CacheProvider, ControllerContext, DataProvider, DevToolsPosition, ErrorBoundary, ErrorBoundary as NetworkErrorBoundary, ProviderProps, StateContext, Store, StoreContext, UniversalSuspense, internal_d as __INTERNAL__, getDefaultManagers, useCache, useCancelling, useController, useDLE, useDebounce, useError, useFetch, useLive, useLoading, usePromisifiedDispatch, useQuery, useSubscription, useSuspense };