Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Improve Controller.getResponse() interface #1396

Merged
merged 1 commit into from Oct 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;
}