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 16bf3de
Show file tree
Hide file tree
Showing 17 changed files with 303 additions and 211 deletions.
52 changes: 50 additions & 2 deletions docs/api/Controller.md
@@ -1,6 +1,7 @@
---
title: Controller
---

<head>
<title>Controller - Imperative Controls for Rest Hooks</title>
</head>
Expand All @@ -19,6 +20,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 @@ -224,8 +228,51 @@ decrement the subscription and if the count reaches 0, more updates won't be rec

## getResponse(endpoint, ...args, state) {#getResponse}

```ts title="returns"
{
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
}
```

Gets the (globally referentially stable) response for a given endpoint/args pair from state given.

### data

The denormalize response data. Guarantees global referential stability for all members.

### expiryStatus

```ts
export enum ExpiryStatus {
Invalid = 1,
InvalidIfStale,
Valid,
}
```

#### Valid

- Will never suspend.
- Might fetch if data is stale

#### InvalidIfStale

- Will suspend if data is stale.
- Might fetch if data is stale

#### Invalid

- Will always suspend
- Will always fetch

### expiresAt

A number representing time when it expires. Compare to Date.now().

### Example

This is used in [useCache](./useCache.md), [useResource](./useResource.md) and can be used in
[Managers](./Manager.md) to lookup a response with the state provided.

Expand All @@ -243,7 +290,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 +310,7 @@ export default class MyManager implements Manager {
action.endpoint,
...(action.meta.args as Parameters<typeof action.endpoint>),
getState(),
),
).data,
);
}
next(action);
Expand All @@ -281,6 +328,7 @@ export default class MyManager implements Manager {
}
```


## getError(endpoint, ...args, state) {#getError}

Gets the error, if any, for a given endpoint. Returns undefined for no errors.
1 change: 1 addition & 0 deletions jest.config.js
Expand Up @@ -10,6 +10,7 @@ const baseConfig = {
'packages/experimental',
'packages/graphql',
'packages/legacy/src/resource',
'packages/core/src/react-integration/hooks/hasUsableData',
],
testURL: 'http://localhost',
};
Expand Down
39 changes: 25 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 Down
5 changes: 5 additions & 0 deletions packages/core/src/controller/Expiry.ts
@@ -0,0 +1,5 @@
export enum ExpiryStatus {
Invalid = 1,
InvalidIfStale,
Valid,
}
31 changes: 29 additions & 2 deletions packages/core/src/controller/__tests__/invalidate.tsx
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import nock from 'nock';
import { FutureArticleResource } from '__tests__/new';
import { FutureArticleResource, GetPhoto } from '__tests__/new';
import { FixtureEndpoint } from '@rest-hooks/test/mockState';
import { act } from '@testing-library/react-hooks';
import { useCache } from '@rest-hooks/core';
import { useCache, useResource } from '@rest-hooks/core';

import { makeRenderRestHook, makeCacheProvider } from '../../../../test';
import useController from '../../react-integration/hooks/useController';
Expand Down Expand Up @@ -123,4 +123,31 @@ describe('invalidate', () => {
}
expect(track.mock.calls.length).toBe(1);
});

it('should work with ArrayBuffer shapes', async () => {
const userId = '5';
const response = new ArrayBuffer(10);
const { result, waitForNextUpdate } = renderRestHook(
() => {
return {
data: useCache(GetPhoto, { userId }),
controller: useController(),
};
},
{
initialFixtures: [
{
endpoint: GetPhoto,
response,
args: [{ userId }],
},
],
},
);
expect(result.current.data).toEqual(response);
await act(async () => {
await result.current.controller.invalidate(GetPhoto, { userId });
});
expect(result.current.data).toBeNull();
});
});
3 changes: 2 additions & 1 deletion packages/core/src/endpoint/adapter.ts
Expand Up @@ -19,12 +19,13 @@ export default function shapeToEndpoint<
> &
Shape['options']
: Shape['options'] & { key: Shape['getFetchKey'] } {
const sideEffect = SIDEEFFECT_TYPES.includes(shape.type);
const options = {
...shape.options,
key: shape.getFetchKey,
schema: shape.schema,
...((sideEffect && { sideEffect }) as any),
};
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);

Expand Down
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
2 changes: 1 addition & 1 deletion packages/core/src/react-integration/hooks/hasUsableData.ts
@@ -1,6 +1,6 @@
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 */
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;
}

0 comments on commit 16bf3de

Please sign in to comment.