From bea53a93c72cc9da4cf4ecd0ca3f57ad5cf6e571 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 16 Oct 2021 22:16:40 -0500 Subject: [PATCH] feat: Improve Controller.getResponse() interface BREAKING CHANGE: useDenormalized() return type changed { data: DenormalizeNullable; expiryStatus: ExpiryStatus; expiresAt: number; } --- docs/api/Controller.md | 7 +- packages/core/src/controller/Controller.ts | 58 ++++-- packages/core/src/controller/Expiry.ts | 5 + packages/core/src/index.ts | 1 + .../__tests__/useExpiresAt.tsx | 15 +- .../react-integration/hooks/hasUsableData.ts | 3 +- .../src/react-integration/hooks/useCache.ts | 27 +-- .../react-integration/hooks/useResource.ts | 32 +-- .../selectors/__tests__/useDenormalized.ts | 184 ++++++++---------- .../src/state/selectors/useDenormalized.ts | 28 ++- packages/legacy/package.json | 2 +- packages/legacy/src/index.ts | 1 + packages/legacy/src/shapeToEndpoint.ts | 32 +++ packages/legacy/src/useStatefulResource.ts | 58 ++++-- 14 files changed, 245 insertions(+), 208 deletions(-) create mode 100644 packages/core/src/controller/Expiry.ts create mode 100644 packages/legacy/src/shapeToEndpoint.ts diff --git a/docs/api/Controller.md b/docs/api/Controller.md index ddd1d3db8342..f4b7cb52bbb3 100644 --- a/docs/api/Controller.md +++ b/docs/api/Controller.md @@ -19,6 +19,9 @@ class Controller { receiveError(endpoint, ...args, error) => Promise; subscribe(endpoint, ...args) => Promise; unsubscribe(endpoint, ...args) => Promise; + /*************** Data Access ***************/ + getResponse(endpoint, ...args, state)​ => { data, expiryStatus, expiresAt }; + getError(endpoint, ...args, state)​ => ErrorTypes | undefined; } ``` @@ -243,7 +246,7 @@ function useCache( ) { const state = useContext(StateContext); const controller = useController(); - return controller.getResponse(endpoint, ...args, state); + return controller.getResponse(endpoint, ...args, state).data; } ``` @@ -263,7 +266,7 @@ export default class MyManager implements Manager { action.endpoint, ...(action.meta.args as Parameters), getState(), - ), + ).data, ); } next(action); diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index 1f157b10361b..4c58d1606bc0 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -6,6 +6,7 @@ import createReset from '@rest-hooks/core/controller/createReset'; import { selectMeta } from '@rest-hooks/core/state/selectors/index'; import createReceive from '@rest-hooks/core/controller/createReceive'; import { NetworkError, UnknownError } from '@rest-hooks/core/types'; +import { ExpiryStatus } from '@rest-hooks/core/controller/Expiry'; import { createUnsubscription, createSubscription, @@ -191,15 +192,16 @@ export default class Controller { return meta?.error as any; }; - getResponse = >( + getResponse = < + E extends Pick, + >( endpoint: E, ...rest: | readonly [...Parameters, State] | readonly [null, State] ): { data: DenormalizeNullable; - suspend: boolean; - found: boolean; + expiryStatus: ExpiryStatus; expiresAt: number; } => { const state = rest[rest.length - 1] as State; @@ -208,6 +210,8 @@ export default class Controller { const key = activeArgs ? endpoint.key(...args) : ''; const cacheResults = activeArgs && state.results[key]; const schema = endpoint.schema; + const meta = selectMeta(state, key); + let expiresAt = meta?.expiresAt; const results = this.getResults( endpoint.schema, @@ -219,13 +223,15 @@ export default class Controller { if (!endpoint.schema || !schemaHasEntity(endpoint.schema)) { return { data: results, - suspend: false, - found: cacheResults !== undefined, - expiresAt: selectMeta(state, key)?.expiresAt || 0, + expiryStatus: meta?.invalidated + ? ExpiryStatus.Invalid + : cacheResults + ? ExpiryStatus.Valid + : ExpiryStatus.InvalidIfStale, + expiresAt: expiresAt || 0, } as { data: DenormalizeNullable; - suspend: boolean; - found: boolean; + expiryStatus: ExpiryStatus; expiresAt: number; }; } @@ -262,7 +268,6 @@ export default class Controller { Record>, ]; - let expiresAt = selectMeta(state, key)?.expiresAt; // fallback to entity expiry time if (!expiresAt) { // expiresAt existance is equivalent to cacheResults @@ -283,11 +288,17 @@ export default class Controller { } } - // only require finding all entities if we are inferring results - // deletion is separate count, and thus will still trigger - // only require finding all entities if we are inferring results - // deletion is separate count, and thus will still trigger - return { data, suspend, found: !!cacheResults || found, expiresAt }; + // https://resthooks.io/docs/getting-started/expiry-policy#expiry-status + // we don't track the difference between stale or fresh because that is tied to triggering + // conditions + const expiryStatus = + meta?.invalidated || (suspend && !meta?.error) + ? ExpiryStatus.Invalid + : suspend || endpoint.invalidIfStale || (!cacheResults && !found) + ? ExpiryStatus.InvalidIfStale + : ExpiryStatus.Valid; + + return { data, expiryStatus, expiresAt }; }; private getResults = ( @@ -300,6 +311,25 @@ export default class Controller { return inferResults(schema, args, indexes); }; + + getShouldSuspend = >( + endpoint: E, + ...rest: + | readonly [...Parameters, State] + | readonly [null, State] + ): ErrorTypes | undefined => { + const state = rest[rest.length - 1] as State; + const args = rest.slice(0, rest.length - 1) as Parameters; + if (args?.[0] === null) return; + const key = endpoint.key(...args); + + const meta = selectMeta(state, key); + const results = state.results[key]; + + if (results !== undefined && meta?.errorPolicy === 'soft') return; + + return meta?.error as any; + }; } /** Determine whether the schema has any entities. diff --git a/packages/core/src/controller/Expiry.ts b/packages/core/src/controller/Expiry.ts new file mode 100644 index 000000000000..eb7e74a63d32 --- /dev/null +++ b/packages/core/src/controller/Expiry.ts @@ -0,0 +1,5 @@ +export enum ExpiryStatus { + Invalid = 1, + InvalidIfStale, + Valid, +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4e592cab9aa8..e5800a4adb0d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,7 @@ export { ControllerContext, } from '@rest-hooks/core/react-integration/context'; export { default as Controller } from '@rest-hooks/core/controller/Controller'; +export { ExpiryStatus } from '@rest-hooks/core/controller/Expiry'; export * from '@rest-hooks/core/controller/types'; export * from '@rest-hooks/core/state/actions/index'; diff --git a/packages/core/src/react-integration/__tests__/useExpiresAt.tsx b/packages/core/src/react-integration/__tests__/useExpiresAt.tsx index 71b29c57724a..780700c20317 100644 --- a/packages/core/src/react-integration/__tests__/useExpiresAt.tsx +++ b/packages/core/src/react-integration/__tests__/useExpiresAt.tsx @@ -82,9 +82,8 @@ describe('useExpiresAt()', () => { const { result } = renderHook( () => { - const [denormalized, ready, deleted, resolvedEntities] = - useDenormalized(ListTaco, {}, state); - return useExpiresAt(ListTaco, {}, resolvedEntities); + const { expiresAt } = useDenormalized(ListTaco, {}, state); + return useExpiresAt(ListTaco, {}, expiresAt); }, { wrapper }, ); @@ -145,9 +144,8 @@ describe('useExpiresAt()', () => { const { result } = renderHook( () => { - const [denormalized, ready, deleted, resolvedEntities] = - useDenormalized(ListTaco, {}, state); - return useExpiresAt(ListTaco, {}, resolvedEntities); + const { expiresAt } = useDenormalized(ListTaco, {}, state); + return useExpiresAt(ListTaco, {}, expiresAt); }, { wrapper }, ); @@ -155,9 +153,8 @@ describe('useExpiresAt()', () => { const { result: result2 } = renderHook( () => { - const [denormalized, ready, deleted, resolvedEntities] = - useDenormalized(DetailTaco, { id: '2' }, state); - return useExpiresAt(DetailTaco, { id: '2' }, resolvedEntities); + const { expiresAt } = useDenormalized(DetailTaco, { id: '2' }, state); + return useExpiresAt(DetailTaco, { id: '2' }, expiresAt); }, { wrapper }, ); diff --git a/packages/core/src/react-integration/hooks/hasUsableData.ts b/packages/core/src/react-integration/hooks/hasUsableData.ts index daf39d4195b1..687aef3723da 100644 --- a/packages/core/src/react-integration/hooks/hasUsableData.ts +++ b/packages/core/src/react-integration/hooks/hasUsableData.ts @@ -1,6 +1,7 @@ import { FetchShape } from '@rest-hooks/core/endpoint/index'; -/** If the invalidIfStale option is set we suspend if resource has expired */ +/** @deprecated use https://resthooks.io/docs/api/Controller#getResponse + * If the invalidIfStale option is set we suspend if resource has expired */ export default function hasUsableData( fetchShape: Pick, 'options'>, cacheReady: boolean, diff --git a/packages/core/src/react-integration/hooks/useCache.ts b/packages/core/src/react-integration/hooks/useCache.ts index 8d529127e753..8a409227cef2 100644 --- a/packages/core/src/react-integration/hooks/useCache.ts +++ b/packages/core/src/react-integration/hooks/useCache.ts @@ -3,12 +3,8 @@ import { DenormalizeNullable } from '@rest-hooks/endpoint'; import { useDenormalized } from '@rest-hooks/core/state/selectors/index'; import { useContext, useMemo } from 'react'; import { StateContext } from '@rest-hooks/core/react-integration/context'; -import { - hasUsableData, - useMeta, - useError, -} from '@rest-hooks/core/react-integration/hooks/index'; import { denormalize, inferResults } from '@rest-hooks/normalizr'; +import { ExpiryStatus } from '@rest-hooks/core/controller/Expiry'; /** * Access a resource if it is available. @@ -24,13 +20,12 @@ export default function useCache< ): DenormalizeNullable { const state = useContext(StateContext); - const [denormalized, ready, deleted, expiresAt] = useDenormalized( + const { data, expiryStatus, expiresAt } = useDenormalized( fetchShape, params, state, ); - const error = useError(fetchShape, params); - const trigger = deleted && !error; + const forceFetch = expiryStatus === ExpiryStatus.Invalid; /*********** This block is to ensure results are only filled when they would not suspend **************/ // This computation reflects the behavior of useResource/useRetrive @@ -38,22 +33,14 @@ export default function useCache< // This way, random unrelated re-renders don't cause the concept of expiry // to change const expired = useMemo(() => { - if ((Date.now() <= expiresAt && !trigger) || !params) return false; + if ((Date.now() <= expiresAt && !forceFetch) || !params) return false; return true; // we need to check against serialized params, since params can change frequently // eslint-disable-next-line react-hooks/exhaustive-deps - }, [expiresAt, params && fetchShape.getFetchKey(params), trigger]); + }, [expiresAt, params && fetchShape.getFetchKey(params), forceFetch]); // if useResource() would suspend, don't include entities from cache - if ( - !hasUsableData( - fetchShape, - ready, - deleted, - useMeta(fetchShape, params)?.invalidated, - ) && - expired - ) { + if (expiryStatus !== ExpiryStatus.Valid && expired) { return denormalize( inferResults(fetchShape.schema, [params], state.indexes), fetchShape.schema, @@ -62,5 +49,5 @@ export default function useCache< } /*********************** end block *****************************/ - return denormalized; + return data; } diff --git a/packages/core/src/react-integration/hooks/useResource.ts b/packages/core/src/react-integration/hooks/useResource.ts index 3af00cf4ad02..6e8014a48540 100644 --- a/packages/core/src/react-integration/hooks/useResource.ts +++ b/packages/core/src/react-integration/hooks/useResource.ts @@ -5,8 +5,7 @@ import { StateContext } from '@rest-hooks/core/react-integration/context'; import { useMemo, useContext } from 'react'; import useRetrieve from '@rest-hooks/core/react-integration/hooks/useRetrieve'; import useError from '@rest-hooks/core/react-integration/hooks/useError'; -import hasUsableData from '@rest-hooks/core/react-integration/hooks/hasUsableData'; -import useMeta from '@rest-hooks/core/react-integration/hooks/useMeta'; +import { ExpiryStatus } from '@rest-hooks/core/controller/Expiry'; type ResourceArgs< S extends ReadShape, @@ -26,7 +25,7 @@ function useOneResource< Denormalize > { const state = useContext(StateContext); - const [data, ready, suspend, expiresAt] = useDenormalized( + const { data, expiryStatus, expiresAt } = useDenormalized( fetchShape, params, state, @@ -36,19 +35,11 @@ function useOneResource< const maybePromise = useRetrieve( fetchShape, params, - suspend && !error, + expiryStatus === ExpiryStatus.Invalid, expiresAt, ); - if ( - !hasUsableData( - fetchShape, - ready, - suspend, - useMeta(fetchShape, params)?.invalidated, - ) && - maybePromise - ) { + if (expiryStatus !== ExpiryStatus.Valid && maybePromise) { throw maybePromise; } @@ -87,20 +78,13 @@ function useManyResources[]>( useRetrieve( fetchShape, params, - denormalizedValues[i][2] && !errorValues[i], - denormalizedValues[i][3], + denormalizedValues[i].expiryStatus === ExpiryStatus.Invalid, + denormalizedValues[i].expiresAt, ), ) // only wait on promises without results .map( - (p, i) => - !hasUsableData( - resourceList[i][0], - denormalizedValues[i][1], - denormalizedValues[i][2], - // eslint-disable-next-line react-hooks/rules-of-hooks - useMeta(...resourceList[i])?.invalidated, - ) && p, + (p, i) => denormalizedValues[i].expiryStatus !== ExpiryStatus.Valid && p, ); // throw first valid error @@ -120,7 +104,7 @@ function useManyResources[]>( if (promise) throw promise; - return denormalizedValues.map(([denormalized]) => denormalized); + return denormalizedValues.map(({ data }) => data); } type CondNull = P extends null ? A : B; diff --git a/packages/core/src/state/selectors/__tests__/useDenormalized.ts b/packages/core/src/state/selectors/__tests__/useDenormalized.ts index fb96527ee5eb..c1e2af05bc70 100644 --- a/packages/core/src/state/selectors/__tests__/useDenormalized.ts +++ b/packages/core/src/state/selectors/__tests__/useDenormalized.ts @@ -11,6 +11,7 @@ import { normalize, NormalizedIndex } from '@rest-hooks/normalizr'; import { initialState } from '@rest-hooks/core/state/reducer'; import { renderHook, act } from '@testing-library/react-hooks'; import { useState } from 'react'; +import { ExpiryStatus } from '@rest-hooks/core/'; import useDenormalized from '../useDenormalized'; @@ -24,16 +25,12 @@ describe('useDenormalized()', () => { useDenormalized(CoolerArticleResource.detailShape(), { id: 5 }, state), ); - it('found should be false', () => { - expect(result.current[1]).toBe(false); - }); - - it('deleted should be false', () => { - expect(result.current[2]).toBe(false); + it('expiryStatus should be InvalidIfStale', () => { + expect(result.current.expiryStatus).toBe(ExpiryStatus.InvalidIfStale); }); it('should provide inferred results with undefined', () => { - expect(result.current[0]).toMatchInlineSnapshot(`undefined`); + expect(result.current.data).toMatchInlineSnapshot(`undefined`); }); }); describe('state is populated just not with our query', () => { @@ -58,16 +55,12 @@ describe('useDenormalized()', () => { ), ); - it('found should be false', () => { - expect(result.current[1]).toBe(false); - }); - - it('deleted should be false', () => { - expect(result.current[2]).toBe(false); + it('expiryStatus should be InvalidIfStale', () => { + expect(result.current.expiryStatus).toBe(ExpiryStatus.InvalidIfStale); }); it('should provide inferred results with undefined', () => { - expect(result.current[0]).toMatchInlineSnapshot(`undefined`); + expect(result.current.data).toMatchInlineSnapshot(`undefined`); }); }); describe('when state exists', () => { @@ -85,23 +78,19 @@ describe('useDenormalized()', () => { state.entityMeta = createEntityMeta(state.entities); const { result: { - current: [value, found, deleted], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(CoolerArticleResource.detailShape(), params, state), ); - it('found should be true', () => { - expect(found).toBe(true); - }); - - it('deleted should be false', () => { - expect(deleted).toBe(false); + it('expiryStatus should be Valid', () => { + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should provide inferred results', () => { - expect(value).toStrictEqual(article); - expect(value).toBeInstanceOf(CoolerArticleResource); + expect(data).toStrictEqual(article); + expect(data).toBeInstanceOf(CoolerArticleResource); }); }); describe('without entity with defined results', () => { @@ -114,22 +103,18 @@ describe('useDenormalized()', () => { }; const { result: { - current: [value, found, deleted], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(CoolerArticleResource.detailShape(), params, state), ); - it('found should be true', () => { - expect(found).toBe(true); - }); - - it('deleted should be false', () => { - expect(deleted).toBe(false); + it('expiryStatus should be Valid', () => { + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should provide inferred results with undefined', () => { - expect(value).toMatchInlineSnapshot(`undefined`); + expect(data).toMatchInlineSnapshot(`undefined`); }); }); describe('no result exists but primary key is used', () => { @@ -144,23 +129,19 @@ describe('useDenormalized()', () => { state.entityMeta = createEntityMeta(state.entities); const { result: { - current: [value, found, deleted], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(CoolerArticleResource.detailShape(), params, state), ); - it('found should be true', () => { - expect(found).toBe(true); - }); - - it('deleted should be false', () => { - expect(deleted).toBe(false); + it('expiryStatus should be Valid', () => { + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should provide inferred results', () => { - expect(value).toStrictEqual(article); - expect(value).toBeInstanceOf(CoolerArticleResource); + expect(data).toStrictEqual(article); + expect(data).toBeInstanceOf(CoolerArticleResource); }); }); describe('no result exists but primary key is used when using nested schema', () => { @@ -176,23 +157,19 @@ describe('useDenormalized()', () => { state.entityMeta = createEntityMeta(state.entities); const { result: { - current: [value, found, deleted], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(PaginatedArticleResource.detailShape(), params, state), ); - it('found should be true', () => { - expect(found).toBe(true); - }); - - it('deleted should be false', () => { - expect(deleted).toBe(false); + it('expiryStatus should be Valid', () => { + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should provide inferred results', () => { - expect(value.data).toStrictEqual(pageArticle); - expect(value.data).toBeInstanceOf(PaginatedArticleResource); + expect(data.data).toStrictEqual(pageArticle); + expect(data.data).toBeInstanceOf(PaginatedArticleResource); }); }); @@ -230,7 +207,7 @@ describe('useDenormalized()', () => { useDenormalized(IndexShape, { username: user.username }, state), { initialProps: { state: localstate } }, ); - expect(result.current[1]).toBe(false); + expect(result.current.expiryStatus).toBe(ExpiryStatus.InvalidIfStale); localstate = { ...localstate, indexes: { @@ -242,9 +219,8 @@ describe('useDenormalized()', () => { } as NormalizedIndex, }; rerender({ state: localstate }); - expect(result.current[1]).toBe(true); - expect(result.current[2]).toBe(false); - expect(result.current[0].data).toStrictEqual(user); + expect(result.current.expiryStatus).toBe(ExpiryStatus.Valid); + expect(result.current.data.data).toStrictEqual(user); }); }); @@ -265,19 +241,19 @@ describe('useDenormalized()', () => { state.entityMeta = createEntityMeta(state.entities); const { result: { - current: [value, found], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(CoolerArticleResource.detailShape(), params, state), ); - it('found should be true', () => { - expect(found).toBe(true); + it('expiryStatus should be Valid', () => { + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should provide inferred results', () => { - expect(value).toStrictEqual(article); - expect(value).toBeInstanceOf(CoolerArticleResource); + expect(data).toStrictEqual(article); + expect(data).toBeInstanceOf(CoolerArticleResource); }); }); it('should throw when results are Array', () => { @@ -327,17 +303,17 @@ describe('useDenormalized()', () => { state.entityMeta = createEntityMeta(state.entities); const { result: { - current: [value, found], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(NestedArticleResource.detailShape(), params, state), ); - it('found should be true', () => { - expect(found).toBe(true); + it('expiryStatus should be Valid', () => { + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should provide inferred results', () => { - expect(value).toMatchInlineSnapshot(` + expect(data).toMatchInlineSnapshot(` NestedArticleResource { "author": null, "content": "head", @@ -367,18 +343,18 @@ describe('useDenormalized()', () => { const state = initialState; const { result: { - current: [value, found], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(PaginatedArticleResource.listShape(), {}, state), ); - it('found should be false', () => { - expect(found).toBe(false); + it('expiryStatus should be InvalidIfStale', () => { + expect(expiryStatus).toBe(ExpiryStatus.InvalidIfStale); }); it('should provide inferred results with undefined for entity', () => { - expect(value).toMatchInlineSnapshot(` + expect(data).toMatchInlineSnapshot(` Object { "nextPage": "", "prevPage": "", @@ -395,18 +371,18 @@ describe('useDenormalized()', () => { const state = initialState; const { result: { - current: [value, found], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(CoolerArticleResource.listShape(), {}, state), ); - it('found should be false', () => { - expect(found).toBe(false); + it('expiryStatus should be InvalidIfStale', () => { + expect(expiryStatus).toBe(ExpiryStatus.InvalidIfStale); }); it('should provide inferred results with undefined for entity', () => { - expect(value).toMatchInlineSnapshot(`undefined`); + expect(data).toMatchInlineSnapshot(`undefined`); }); }); describe('state exists', () => { @@ -424,18 +400,18 @@ describe('useDenormalized()', () => { }; const { result: { - current: [value, found], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(CoolerArticleResource.listShape(), params, state), ); it('found should be true', () => { - expect(found).toBe(true); + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should provide inferred results', () => { - expect(value).toStrictEqual(articles); + expect(data).toStrictEqual(articles); }); }); describe('missing some ids in entities table', () => { @@ -454,7 +430,7 @@ describe('useDenormalized()', () => { }; const { result: { - current: [value, found], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(CoolerArticleResource.listShape(), params, state), @@ -462,12 +438,12 @@ describe('useDenormalized()', () => { const expectedArticles = articles.slice(1); - it('found should be true', () => { - expect(found).toBe(true); + it('expiryStatus should be Valid', () => { + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should simply ignore missing entities', () => { - expect(value).toEqual(expectedArticles); + expect(data).toEqual(expectedArticles); }); }); describe('paginated results + missing some ids in entities table', () => { @@ -487,19 +463,19 @@ describe('useDenormalized()', () => { }; const { result: { - current: [value, found], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(PaginatedArticleResource.listShape(), params, state), ); - it('found should be true', () => { - expect(found).toBe(true); + it('expiryStatus should be Valid', () => { + expect(expiryStatus).toBe(ExpiryStatus.Valid); }); it('should match normalized articles', () => { const expectedArticles = articles.slice(1); - expect(value.results).toEqual(expectedArticles); + expect(data.results).toEqual(expectedArticles); }); }); describe('paginated results', () => { @@ -538,24 +514,24 @@ describe('useDenormalized()', () => { result = v.result; }); - it('found should be true', () => { - expect(result.current.ret[1]).toBe(true); + it('expiryStatus should be Valid', () => { + expect(result.current.ret.expiryStatus).toBe(ExpiryStatus.Valid); }); it('should match normalized articles', () => { - expect(result.current.ret[0].results).toEqual(articles); + expect(result.current.ret.data.results).toEqual(articles); }); it('should stay referentially equal with external entity changes', () => { - const prevValue = result.current.ret[0]; + const prevValue = result.current.ret.data; act(() => result.current.setState((state: any) => ({ ...state, entities: { ...state.entities, whatever: {} }, })), ); - expect(result.current.ret[0]).toBe(prevValue); - expect(result.current.ret[0].results).toBe(prevValue.results); + expect(result.current.ret.data).toBe(prevValue); + expect(result.current.ret.data.results).toBe(prevValue.results); act(() => result.current.setState((state: any) => { @@ -573,12 +549,12 @@ describe('useDenormalized()', () => { return { ...ret, entityMeta: createEntityMeta(state.entities) }; }), ); - expect(result.current.ret[0]).toBe(prevValue); - expect(result.current.ret[0].results).toBe(prevValue.results); + expect(result.current.ret.data).toBe(prevValue); + expect(result.current.ret.data.results).toBe(prevValue.results); }); it('should referentially change when an entity changes', () => { - const prevValue = result.current.ret[0]; + const prevValue = result.current.ret.data; act(() => result.current.setState((state: any) => ({ ...state, @@ -591,11 +567,11 @@ describe('useDenormalized()', () => { }, })), ); - expect(result.current.ret[0]).not.toBe(prevValue); + expect(result.current.ret.data).not.toBe(prevValue); }); it('should referentially change when the result extends', () => { - const prevValue = result.current.ret[0]; + const prevValue = result.current.ret.data; act(() => result.current.setState((state: any) => ({ ...state, @@ -607,8 +583,8 @@ describe('useDenormalized()', () => { }, })), ); - expect(result.current.ret[0]).not.toBe(prevValue); - expect(result.current.ret[0]).toMatchSnapshot(); + expect(result.current.ret.data).not.toBe(prevValue); + expect(result.current.ret.data).toMatchSnapshot(); }); }); @@ -624,18 +600,18 @@ describe('useDenormalized()', () => { }; const { result: { - current: [value, found], + current: { data, expiryStatus, expiresAt }, }, } = renderHook(() => useDenormalized(PaginatedArticleResource.listShape(), params, state), ); - it('found should be false', () => { - expect(found).toBe(false); + it('expiryStatus should be InvalidIfStale', () => { + expect(expiryStatus).toBe(ExpiryStatus.InvalidIfStale); }); it('value should be inferred for pagination primitives', () => { - expect(value).toMatchInlineSnapshot(` + expect(data).toMatchInlineSnapshot(` Object { "nextPage": "", "prevPage": "", @@ -650,7 +626,11 @@ describe('useDenormalized()', () => { const { result } = renderHook(() => { return useDenormalized(photoShape, { userId }, initialState as any); }); - expect(result.current).toStrictEqual([null, false, false, 0]); + expect(result.current).toStrictEqual({ + data: null, + expiresAt: 0, + expiryStatus: ExpiryStatus.InvalidIfStale, + }); }); it('should return results as-is for schemas with no entities', () => { @@ -666,7 +646,11 @@ describe('useDenormalized()', () => { const { result } = renderHook(() => { return useDenormalized(photoShape, { userId }, state); }); - expect(result.current).toStrictEqual([results, true, false, 0]); + expect(result.current).toStrictEqual({ + data: results, + expiresAt: 0, + expiryStatus: ExpiryStatus.Valid, + }); }); it('should throw with invalid schemas', () => { diff --git a/packages/core/src/state/selectors/useDenormalized.ts b/packages/core/src/state/selectors/useDenormalized.ts index c5e4ac611160..05c2a5ea411e 100644 --- a/packages/core/src/state/selectors/useDenormalized.ts +++ b/packages/core/src/state/selectors/useDenormalized.ts @@ -1,18 +1,15 @@ import { State } from '@rest-hooks/core/types'; import { ReadShape, ParamsFromShape } from '@rest-hooks/core/endpoint/index'; import { DenormalizeNullable } from '@rest-hooks/endpoint'; -import { isEntity, Schema } from '@rest-hooks/endpoint'; -import { - DenormalizeCache, - WeakListMap, - denormalize, - inferResults, -} from '@rest-hooks/normalizr'; +import { Schema } from '@rest-hooks/endpoint'; import { useMemo } from 'react'; import useController from '@rest-hooks/core/react-integration/hooks/useController'; import shapeToEndpoint from '@rest-hooks/core/endpoint/adapter'; +import { ExpiryStatus } from '@rest-hooks/core/controller/Expiry'; /** + * @deprecated use https://resthooks.io/docs/api/Controller#getResponse directly instead + * * Selects the denormalized form from `state` cache. * * If `result` is not found, will attempt to generate it naturally @@ -32,12 +29,11 @@ export default function useDenormalized< state: State, /** @deprecated */ denormalizeCache?: any, -): [ - denormalized: DenormalizeNullable, - found: typeof params extends null ? false : boolean, - deleted: boolean, - expiresAt: number, -] { +): { + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; +} { const controller = useController(); const endpoint = useMemo(() => { @@ -50,11 +46,10 @@ export default function useDenormalized< const cacheResults = params && state.results[key]; // Compute denormalized value - const { data, found, suspend, expiresAt } = useMemo(() => { + return useMemo(() => { return controller.getResponse(endpoint, params, state) as { data: DenormalizeNullable; - found: boolean; - suspend: boolean; + expiryStatus: ExpiryStatus; expiresAt: number; }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -66,5 +61,4 @@ export default function useDenormalized< key, cacheResults, ]); - return [data, found as any, suspend, expiresAt]; } diff --git a/packages/legacy/package.json b/packages/legacy/package.json index f7d698ffe9c4..010ae9a8de96 100644 --- a/packages/legacy/package.json +++ b/packages/legacy/package.json @@ -95,7 +95,7 @@ "url": "https://github.com/coinbase/rest-hooks/issues" }, "peerDependencies": { - "@rest-hooks/core": "^1.4.1 || ^2.0.0-0", + "@rest-hooks/core": "^3.0.0-0", "@types/react": "^16.8.4 || ^17.0.0 || ^18.0.0-0", "react": "^16.8.4 || ^17.0.0 || ^18.0.0-0" }, diff --git a/packages/legacy/src/index.ts b/packages/legacy/src/index.ts index 59ab3cad65a0..8a841c6dc258 100644 --- a/packages/legacy/src/index.ts +++ b/packages/legacy/src/index.ts @@ -1,5 +1,6 @@ export * from '@rest-hooks/legacy/resource/index'; export { default as useStatefulResource } from '@rest-hooks/legacy/useStatefulResource'; +export { default as shapeToEndpoint } from '@rest-hooks/legacy/shapeToEndpoint'; export type { FetchShape, ReadShape, diff --git a/packages/legacy/src/shapeToEndpoint.ts b/packages/legacy/src/shapeToEndpoint.ts new file mode 100644 index 000000000000..cf471b6b6f06 --- /dev/null +++ b/packages/legacy/src/shapeToEndpoint.ts @@ -0,0 +1,32 @@ +import { Endpoint } from '@rest-hooks/endpoint'; +import type { EndpointInstance } from '@rest-hooks/endpoint'; +import type { FetchShape } from '@rest-hooks/core/endpoint/shapes'; + +type ShapeTypeToSideEffect = + T extends 'read' | undefined ? undefined : true; + +const SIDEEFFECT_TYPES: (string | undefined)[] = ['mutate', 'delete']; + +export default function shapeToEndpoint< + Shape extends Partial>, +>( + shape: Shape, +): Shape['fetch'] extends (...args: any) => Promise + ? EndpointInstance< + Shape['fetch'], + Shape['schema'], + ShapeTypeToSideEffect + > & + Shape['options'] + : Shape['options'] & { key: Shape['getFetchKey'] } { + const options = { + ...shape.options, + key: shape.getFetchKey, + schema: shape.schema, + }; + if (SIDEEFFECT_TYPES.includes(shape.type)) (options as any).sideEffect = true; + if (Object.prototype.hasOwnProperty.call(shape, 'fetch')) + return new Endpoint(shape.fetch as any, options); + + return options as any; +} diff --git a/packages/legacy/src/useStatefulResource.ts b/packages/legacy/src/useStatefulResource.ts index 9a6ff73bdc8c..2e48db980510 100644 --- a/packages/legacy/src/useStatefulResource.ts +++ b/packages/legacy/src/useStatefulResource.ts @@ -2,13 +2,12 @@ import { useRetrieve, useError, Schema, - useDenormalized, StateContext, - hasUsableData, - useMeta, ParamsFromShape, ReadShape, __INTERNAL__, + ExpiryStatus, + useController, } from '@rest-hooks/core'; import type { Denormalize, @@ -16,7 +15,8 @@ import type { ErrorTypes, } from '@rest-hooks/core'; import { denormalize } from '@rest-hooks/normalizr'; -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; +import shapeToEndpoint from '@rest-hooks/legacy/shapeToEndpoint'; const { inferResults } = __INTERNAL__; @@ -47,38 +47,56 @@ export default function useStatefulResource< Params extends ParamsFromShape | null, >(fetchShape: Shape, params: Params): StatefulReturn { const state = useContext(StateContext); - const [denormalized, ready, deleted, entitiesExpireAt] = useDenormalized( - fetchShape, - params, - state, - ); + const controller = useController(); + + const endpoint = useMemo(() => { + return shapeToEndpoint(fetchShape); + // we currently don't support shape changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const key = params !== null ? endpoint.key(params) : ''; + const cacheResults = params && state.results[key]; + + // Compute denormalized value + // eslint-disable-next-line prefer-const + let { data, expiryStatus, expiresAt } = useMemo(() => { + return controller.getResponse(endpoint, params, state) as { + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + cacheResults, + state.indexes, + state.entities, + state.entityMeta, + key, + cacheResults, + ]); + const error = useError(fetchShape, params); const maybePromise: Promise | undefined = useRetrieve( fetchShape, params, - deleted && !error, - entitiesExpireAt, + expiryStatus === ExpiryStatus.Invalid, + expiresAt, ); if (maybePromise) { maybePromise.catch(() => {}); } - const loading = - !hasUsableData( - fetchShape, - ready, - deleted, - useMeta(fetchShape, params)?.invalidated, - ) && !!maybePromise; - const data = loading + const loading = expiryStatus !== ExpiryStatus.Valid && !!maybePromise; + data = loading ? denormalize( inferResults(fetchShape.schema, [params], state.indexes), fetchShape.schema, {}, )[0] - : denormalized; + : data; return { data,