Skip to content

Commit

Permalink
feat: Add controller.fetchIfStale() (#2743)
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker committed Aug 13, 2023
1 parent 7de49e4 commit 5cedd44
Show file tree
Hide file tree
Showing 13 changed files with 203 additions and 22 deletions.
34 changes: 34 additions & 0 deletions .changeset/tidy-kiwis-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@data-client/core': minor
'@data-client/react': minor
'@rest-hooks/core': minor
'@rest-hooks/react': minor
---

Add controller.fetchIfStale()

Fetches only if endpoint is considered '[stale](../concepts/expiry-policy.md#stale)'; otherwise returns undefined.

This can be useful when prefetching data, as it avoids overfetching fresh data.

An [example](https://stackblitz.com/github/data-client/rest-hooks/tree/master/examples/github-app?file=src%2Frouting%2Froutes.tsx) with a fetch-as-you-render router:

```ts
{
name: 'IssueList',
component: lazyPage('IssuesPage'),
title: 'issue list',
resolveData: async (
controller: Controller,
{ owner, repo }: { owner: string; repo: string },
searchParams: URLSearchParams,
) => {
const q = searchParams?.get('q') || 'is:issue is:open';
await controller.fetchIfStale(IssueResource.search, {
owner,
repo,
q,
});
},
},
```
31 changes: 31 additions & 0 deletions docs/core/api/Controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import TabItem from '@theme/TabItem';
class Controller {
/*************** Action Dispatchers ***************/
fetch(endpoint, ...args): ReturnType<E>;
fetchIfStale(endpoint, ...args): ReturnType<E> | undefined;
expireAll({ testKey }): Promise<void>;
invalidate(endpoint, ...args): Promise<void>;
invalidateAll({ testKey }): Promise<void>;
Expand Down Expand Up @@ -144,6 +145,36 @@ post.pk();
- Identical requests are deduplicated globally; allowing only one inflight request at a time.
- To ensure a _new_ request is started, make sure to abort any existing inflight requests.

## fetchIfStale(endpoint, ...args) {#fetchIfStale}

Fetches only if endpoint is considered '[stale](../concepts/expiry-policy.md#stale)'; otherwise returns undefined.

This can be useful when prefetching data, as it avoids overfetching fresh data.

An [example](https://stackblitz.com/github/data-client/rest-hooks/tree/master/examples/github-app?file=src%2Frouting%2Froutes.tsx) with a fetch-as-you-render router:

```ts
{
name: 'IssueList',
component: lazyPage('IssuesPage'),
title: 'issue list',
resolveData: async (
controller: Controller,
{ owner, repo }: { owner: string; repo: string },
searchParams: URLSearchParams,
) => {
const q = searchParams?.get('q') || 'is:issue is:open';
// highlight-start
await controller.fetchIfStale(IssueResource.search, {
owner,
repo,
q,
});
// highlight-end
},
},
```

## expireAll({ testKey }) {#expireAll}

Sets all responses' [expiry status](../concepts/expiry-policy.md) matching `testKey` to [Stale](../concepts/expiry-policy.md#stale).
Expand Down
4 changes: 2 additions & 2 deletions examples/github-app/src/pages/IssueList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLive } from '@data-client/react';
import { useSuspense } from '@data-client/react';
import { List } from 'antd';
import parseLink from 'parse-link-header';
import { Issue, IssueResource } from 'resources/Issue';
Expand All @@ -10,7 +10,7 @@ export default function IssueList({ owner, repo, page, q }: Props) {
const {
results: { items: issues },
link,
} = useLive(IssueResource.search, {
} = useSuspense(IssueResource.search, {
owner,
repo,
q,
Expand Down
24 changes: 14 additions & 10 deletions examples/github-app/src/routing/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const routes = [
controller: Controller,
{ owner, repo }: { owner: string; repo: string },
) => {
controller.fetch(IssueResource.search, { owner, repo });
controller.fetchIfStale(IssueResource.search, { owner, repo });
},
},
{
Expand All @@ -47,7 +47,7 @@ export const routes = [
searchParams: URLSearchParams,
) => {
const q = searchParams?.get('q') || 'is:pr is:open';
await controller.fetch(IssueResource.search, {
await controller.fetchIfStale(IssueResource.search, {
owner,
repo,
q,
Expand All @@ -64,7 +64,7 @@ export const routes = [
searchParams: URLSearchParams,
) => {
const q = searchParams?.get('q') || 'is:issue is:open';
await controller.fetch(IssueResource.search, {
await controller.fetchIfStale(IssueResource.search, {
owner,
repo,
q,
Expand All @@ -78,9 +78,13 @@ export const routes = [
controller: Controller,
{ owner, repo, number }: { owner: string; repo: string; number: string },
) => {
controller.fetch(ReactionResource.getList, { owner, repo, number });
controller.fetch(CommentResource.getList, { owner, repo, number });
await controller.fetch(IssueResource.get, { owner, repo, number });
controller.fetchIfStale(ReactionResource.getList, {
owner,
repo,
number,
});
controller.fetchIfStale(CommentResource.getList, { owner, repo, number });
await controller.fetchIfStale(IssueResource.get, { owner, repo, number });
},
},
{
Expand All @@ -90,15 +94,15 @@ export const routes = [
controller: Controller,
{ login }: { login: string },
) => {
controller.fetch(UserResource.get, { login });
controller.fetch(RepositoryResource.getByUser, { login });
controller.fetchIfStale(UserResource.get, { login });
controller.fetchIfStale(RepositoryResource.getByUser, { login });
const { data: currentUser } = controller.getResponse(
UserResource.current,
controller.getState(),
);
if (currentUser)
controller.fetch(RepositoryResource.getByPinned, { login });
controller.fetch(EventResource.getList, { login });
controller.fetchIfStale(RepositoryResource.getByPinned, { login });
controller.fetchIfStale(EventResource.getList, { login });
},
},
];
24 changes: 24 additions & 0 deletions packages/core/src/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ export default class Controller<
return action.meta.promise as any;
};

/**
* Fetches only if endpoint is considered 'stale'; otherwise returns undefined
* @see https://dataclient.io/docs/api/Controller#fetchIfStale
*/
fetchIfStale = <
E extends EndpointInterface & { update?: EndpointUpdateFunction<E> },
>(
endpoint: E,
...args: readonly [...Parameters<E>]
):
| (E['schema'] extends undefined | null
? ReturnType<E>
: Promise<Denormalize<E['schema']>>)
| undefined => {
const { expiresAt, expiryStatus } = this.getResponse(
endpoint,
...args,
this.getState(),
);
if (expiryStatus !== ExpiryStatus.Invalid && Date.now() <= expiresAt)
return;
return this.fetch(endpoint, ...args);
};

/**
* Forces refetching and suspense on useSuspense with the same Endpoint and parameters.
* @see https://resthooks.io/docs/api/Controller#invalidate
Expand Down
78 changes: 76 additions & 2 deletions packages/core/src/controller/__tests__/Controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Endpoint, Entity } from '@data-client/endpoint';
import { normalize } from '@data-client/normalizr';
import { CoolerArticleResource } from '__tests__/new';
import { createEntityMeta } from '__tests__/utils';

import { ExpiryStatus } from '../..';
import { initialState } from '../../state/reducer/createReducer';
import Contoller from '../Controller';
import Controller from '../Controller';

function ignoreError(e: Event) {
e.preventDefault();
Expand All @@ -19,11 +21,83 @@ afterEach(() => {

describe('Controller', () => {
it('warns when dispatching during middleware setup', () => {
const controller = new Contoller();
const controller = new Controller();
expect(() =>
controller.fetch(CoolerArticleResource.get, { id: 5 }),
).toThrowErrorMatchingInlineSnapshot(
`"Dispatching while constructing your middleware is not allowed. Other middleware would not be applied to this dispatch."`,
);
});

describe('fetchIfStale', () => {
it('should NOT fetch if result is NOT stale', () => {
const payload = {
id: 5,
title: 'hi ho',
content: 'whatever',
tags: ['a', 'best', 'react'],
};
const { entities, result } = normalize(
payload,
CoolerArticleResource.get.schema,
);
const fetchKey = CoolerArticleResource.get.key({ id: payload.id });
const state = {
...initialState,
entities,
results: {
[fetchKey]: result,
},
entityMeta: createEntityMeta(entities),
meta: {
[fetchKey]: {
date: Date.now(),
expiresAt: Date.now() + 10000,
},
},
};
const getState = () => state;
const controller = new Controller({
dispatch: jest.fn(() => Promise.resolve()),
getState,
});
controller.fetchIfStale(CoolerArticleResource.get, { id: payload.id });
expect(controller.dispatch.mock.calls.length).toBe(0);
});
it('should fetch if result stale', () => {
const payload = {
id: 5,
title: 'hi ho',
content: 'whatever',
tags: ['a', 'best', 'react'],
};
const { entities, result } = normalize(
payload,
CoolerArticleResource.get.schema,
);
const fetchKey = CoolerArticleResource.get.key({ id: payload.id });
const state = {
...initialState,
entities,
results: {
[fetchKey]: result,
},
entityMeta: createEntityMeta(entities),
meta: {
[fetchKey]: {
date: 0,
expiresAt: 0,
},
},
};
const getState = () => state;
const controller = new Controller({
dispatch: jest.fn(() => Promise.resolve()),
getState,
});
controller.fetchIfStale(CoolerArticleResource.get, { id: payload.id });

expect(controller.dispatch.mock.calls.length).toBe(1);
});
});
});
2 changes: 1 addition & 1 deletion packages/img/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"@data-client/endpoint": "^0.2.0"
},
"peerDependencies": {
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0",
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
"@types/react": "^16.8.4 || ^17.0.0 || ^18.0.0-0",
"react": "^16.8.4 || ^17.0.0 || ^18.0.0-0"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/redux/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"@data-client/core": "^0.3.0"
},
"peerDependencies": {
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0",
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
"@types/react": "^16.8.4 || ^17.0.0 || ^18.0.0",
"react": "^16.8.4 || ^17.0.0 || ^18.0.0",
"redux": "^4.0.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"@babel/runtime": "^7.17.0"
},
"peerDependencies": {
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0",
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
"@data-client/redux": "^0.1.0 || ^0.2.0 || ^0.3.0",
"@types/react": "^16.8.4 || ^17.0.0 || ^18.0.0-0",
"next": ">=12.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
"react-error-boundary": "^4.0.0"
},
"peerDependencies": {
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0",
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
"@testing-library/react-hooks": "^8.0.0",
"@types/react": "^16.8.4 || ^17.0.0 || ^18.0.0",
"@types/react-dom": "*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,13 @@ declare class Controller<D extends GenericDispatch = DataClientDispatch> {
fetch: <E extends EndpointInterface<FetchFunction, Schema | undefined, boolean | undefined> & {
update?: EndpointUpdateFunction<E> | undefined;
}>(endpoint: E, ...args_0: Parameters<E>) => E["schema"] extends null | undefined ? ReturnType<E> : Promise<Denormalize<E["schema"]>>;
/**
* Fetches the endpoint with given args, updating the Rest Hooks cache with the response or error upon completion.
* @see https://dataclient.io/docs/api/Controller#fetchIfStale
*/
fetchIfStale: <E extends EndpointInterface<FetchFunction, Schema | undefined, boolean | undefined> & {
update?: EndpointUpdateFunction<E> | undefined;
}>(endpoint: E, ...args_0: Parameters<E>) => (E["schema"] extends null | undefined ? ReturnType<E> : Promise<Denormalize<E["schema"]>>) | undefined;
/**
* Forces refetching and suspense on useSuspense with the same Endpoint and parameters.
* @see https://resthooks.io/docs/api/Controller#invalidate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,13 @@ declare class Controller<D extends GenericDispatch = DataClientDispatch> {
fetch: <E extends EndpointInterface<FetchFunction, Schema | undefined, boolean | undefined> & {
update?: EndpointUpdateFunction<E> | undefined;
}>(endpoint: E, ...args_0: Parameters<E>) => E["schema"] extends null | undefined ? ReturnType<E> : Promise<Denormalize<E["schema"]>>;
/**
* Fetches the endpoint with given args, updating the Rest Hooks cache with the response or error upon completion.
* @see https://dataclient.io/docs/api/Controller#fetchIfStale
*/
fetchIfStale: <E extends EndpointInterface<FetchFunction, Schema | undefined, boolean | undefined> & {
update?: EndpointUpdateFunction<E> | undefined;
}>(endpoint: E, ...args_0: Parameters<E>) => (E["schema"] extends null | undefined ? ReturnType<E> : Promise<Denormalize<E["schema"]>>) | undefined;
/**
* Forces refetching and suspense on useSuspense with the same Endpoint and parameters.
* @see https://resthooks.io/docs/api/Controller#invalidate
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3088,7 +3088,7 @@ __metadata:
"@types/node": ^20.0.0
"@types/react": ^18.0.30
peerDependencies:
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0
"@types/react": ^16.8.4 || ^17.0.0 || ^18.0.0-0
react: ^16.8.4 || ^17.0.0 || ^18.0.0-0
peerDependenciesMeta:
Expand Down Expand Up @@ -3144,7 +3144,7 @@ __metadata:
"@types/react": ^18.0.30
redux: ^4.2.1
peerDependencies:
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0
"@types/react": ^16.8.4 || ^17.0.0 || ^18.0.0
react: ^16.8.4 || ^17.0.0 || ^18.0.0
redux: ^4.0.0
Expand Down Expand Up @@ -3182,7 +3182,7 @@ __metadata:
react-dom: ^18.2.0
redux: ^4.2.1
peerDependencies:
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0
"@data-client/redux": ^0.1.0 || ^0.2.0 || ^0.3.0
"@types/react": ^16.8.4 || ^17.0.0 || ^18.0.0-0
next: ">=12.0.0"
Expand Down Expand Up @@ -3213,7 +3213,7 @@ __metadata:
jest: ^29.5.0
react-error-boundary: ^4.0.0
peerDependencies:
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0
"@testing-library/react-hooks": ^8.0.0
"@types/react": ^16.8.4 || ^17.0.0 || ^18.0.0
"@types/react-dom": "*"
Expand Down

1 comment on commit 5cedd44

@vercel
Copy link

@vercel vercel bot commented on 5cedd44 Aug 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.