Skip to content

Commit

Permalink
feat: Improve Controller.getResponse() interface
Browse files Browse the repository at this point in the history
BREAKING CHANGE: useDenormalized() return type changed
{
  data: DenormalizeNullable<Shape['schema']>;
  expiryStatus: ExpiryStatus;
  expiresAt: number;
}
  • Loading branch information
ntucker committed Oct 17, 2021
1 parent 2ac471d commit bea53a9
Show file tree
Hide file tree
Showing 14 changed files with 245 additions and 208 deletions.
7 changes: 5 additions & 2 deletions docs/api/Controller.md
Expand Up @@ -19,6 +19,9 @@ class Controller {
receiveError(endpoint, ...args, error) => Promise<void>;
subscribe(endpoint, ...args) => Promise<void>;
unsubscribe(endpoint, ...args) => Promise<void>;
/*************** Data Access ***************/
getResponse(endpoint, ...args, state)​ => { data, expiryStatus, expiresAt };
getError(endpoint, ...args, state)​ => ErrorTypes | undefined;
}
```

Expand Down Expand Up @@ -243,7 +246,7 @@ function useCache<E extends EntityInterface>(
) {
const state = useContext(StateContext);
const controller = useController();
return controller.getResponse(endpoint, ...args, state);
return controller.getResponse(endpoint, ...args, state).data;
}
```

Expand All @@ -263,7 +266,7 @@ export default class MyManager implements Manager {
action.endpoint,
...(action.meta.args as Parameters<typeof action.endpoint>),
getState(),
),
).data,
);
}
next(action);
Expand Down
58 changes: 44 additions & 14 deletions packages/core/src/controller/Controller.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -191,15 +192,16 @@ export default class Controller {
return meta?.error as any;
};

getResponse = <E extends Pick<EndpointInterface, 'key' | 'schema'>>(
getResponse = <
E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>,
>(
endpoint: E,
...rest:
| readonly [...Parameters<E['key']>, State<unknown>]
| readonly [null, State<unknown>]
): {
data: DenormalizeNullable<E['schema']>;
suspend: boolean;
found: boolean;
expiryStatus: ExpiryStatus;
expiresAt: number;
} => {
const state = rest[rest.length - 1] as State<unknown>;
Expand All @@ -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,
Expand All @@ -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<E['schema']>;
suspend: boolean;
found: boolean;
expiryStatus: ExpiryStatus;
expiresAt: number;
};
}
Expand Down Expand Up @@ -262,7 +268,6 @@ export default class Controller {
Record<string, Record<string, any>>,
];

let expiresAt = selectMeta(state, key)?.expiresAt;
// fallback to entity expiry time
if (!expiresAt) {
// expiresAt existance is equivalent to cacheResults
Expand All @@ -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 = (
Expand All @@ -300,6 +311,25 @@ export default class Controller {

return inferResults(schema, args, indexes);
};

getShouldSuspend = <E extends Pick<EndpointInterface, 'key'>>(
endpoint: E,
...rest:
| readonly [...Parameters<E['key']>, State<unknown>]
| readonly [null, State<unknown>]
): ErrorTypes | undefined => {
const state = rest[rest.length - 1] as State<unknown>;
const args = rest.slice(0, rest.length - 1) as Parameters<E['key']>;
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.
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/controller/Expiry.ts
@@ -0,0 +1,5 @@
export enum ExpiryStatus {
Invalid = 1,
InvalidIfStale,
Valid,
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Expand Up @@ -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';
Expand Down
15 changes: 6 additions & 9 deletions packages/core/src/react-integration/__tests__/useExpiresAt.tsx
Expand Up @@ -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 },
);
Expand Down Expand Up @@ -145,19 +144,17 @@ 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 },
);
expect(result.current).toBe(500);

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 },
);
Expand Down
3 changes: 2 additions & 1 deletion 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<FetchShape<any>, 'options'>,
cacheReady: boolean,
Expand Down
27 changes: 7 additions & 20 deletions packages/core/src/react-integration/hooks/useCache.ts
Expand Up @@ -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.
Expand All @@ -24,36 +20,27 @@ export default function useCache<
): DenormalizeNullable<Shape['schema']> {
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
// It only changes the value when expiry or params change.
// 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,
Expand All @@ -62,5 +49,5 @@ export default function useCache<
}
/*********************** end block *****************************/

return denormalized;
return data;
}
32 changes: 8 additions & 24 deletions packages/core/src/react-integration/hooks/useResource.ts
Expand Up @@ -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<any, any>,
Expand All @@ -26,7 +25,7 @@ function useOneResource<
Denormalize<Shape['schema']>
> {
const state = useContext(StateContext);
const [data, ready, suspend, expiresAt] = useDenormalized(
const { data, expiryStatus, expiresAt } = useDenormalized(
fetchShape,
params,
state,
Expand All @@ -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;
}

Expand Down Expand Up @@ -87,20 +78,13 @@ function useManyResources<A extends ResourceArgs<any, any>[]>(
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
Expand All @@ -120,7 +104,7 @@ function useManyResources<A extends ResourceArgs<any, any>[]>(

if (promise) throw promise;

return denormalizedValues.map(([denormalized]) => denormalized);
return denormalizedValues.map(({ data }) => data);
}

type CondNull<P, A, B> = P extends null ? A : B;
Expand Down

0 comments on commit bea53a9

Please sign in to comment.