Skip to content

Commit

Permalink
feat: Allow ctrl.set() value to be a function (#3129)
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker committed Jun 22, 2024
1 parent c18fbf7 commit 2503402
Show file tree
Hide file tree
Showing 9 changed files with 138 additions and 15 deletions.
15 changes: 15 additions & 0 deletions .changeset/smart-oranges-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@data-client/core': patch
'@data-client/react': patch
---

Allow ctrl.set() value to be a function

This [prevents race conditions](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state).

```ts
const id = '2';
ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 }));
```

Note: the response must include values sufficient to compute Entity.pk()
9 changes: 8 additions & 1 deletion docs/core/api/Controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class Controller {
invalidate(endpoint, ...args): Promise<void>;
invalidateAll({ testKey }): Promise<void>;
resetEntireStore(): Promise<void>;
set(queryable, ...args, response): Promise<void>;
set(queryable, ...args, value): Promise<void>;
setResponse(endpoint, ...args, response): Promise<void>;
setError(endpoint, ...args, error): Promise<void>;
resolve(endpoint, { args, response, fetchedAt, error }): Promise<void>;
Expand Down Expand Up @@ -367,6 +367,13 @@ Updates any [Queryable](/rest/api/schema#queryable) [Schema](/rest/api/schema#sc
ctrl.set(Todo, { id: '5' }, { id: '5', title: 'tell me friends how great Data Client is' });
```

Functions can be used in the value when derived data is used. This [prevents race conditions](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state).

```ts
const id = '2';
ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 }));
```

## setResponse(endpoint, ...args, response) {#setResponse}

Stores `response` in cache for given [Endpoint](/rest/api/Endpoint) and args.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface SetAction<S extends Queryable = any> {
type: typeof SET_TYPE;
schema: S;
meta: SetMeta;
value: Denormalize<S>;
value: {} | ((previousValue: Denormalize<S>) => {});
}

/* setResponse */
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,28 @@ export default class Controller<
* Sets value for the Queryable and args.
* @see https://dataclient.io/docs/api/Controller#set
*/
set = <S extends Queryable>(
set<S extends Queryable>(
schema: S,
...rest: readonly [...SchemaArgs<S>, (previousValue: Denormalize<S>) => {}]
): Promise<void>;

set<S extends Queryable>(
schema: S,
...rest: readonly [...SchemaArgs<S>, {}]
): Promise<void>;

set<S extends Queryable>(
schema: S,
...rest: readonly [...SchemaArgs<S>, any]
): Promise<void> => {
const value: Denormalize<S> = rest[rest.length - 1];
): Promise<void> {
const value = rest[rest.length - 1];
const action = createSet(schema, {
args: rest.slice(0, rest.length - 1) as SchemaArgs<S>,
value,
});
// TODO: reject with error if this fails in reducer
return this.dispatch(action);
};
}

/**
* Sets response for the Endpoint and args.
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/controller/createSet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { Queryable, SchemaArgs } from '@data-client/normalizr';
import type {
Denormalize,
Queryable,
SchemaArgs,
} from '@data-client/normalizr';

import ensurePojo from './ensurePojo.js';
import { SET_TYPE } from '../actionTypes.js';
Expand All @@ -12,7 +16,7 @@ export default function createSet<S extends Queryable>(
value,
}: {
args: readonly [...SchemaArgs<S>];
value: any;
value: {} | ((previousValue: Denormalize<S>) => {});
fetchedAt?: number;
},
): SetAction<S> {
Expand Down
71 changes: 70 additions & 1 deletion packages/core/src/state/__tests__/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { INVALID } from '@data-client/endpoint';
import { INVALID, Entity } from '@data-client/endpoint';
import { ArticleResource, Article, PaginatedArticle } from '__tests__/new';

import { Controller } from '../..';
Expand Down Expand Up @@ -202,6 +202,75 @@ describe('reducer', () => {
});
});

it('set(function) should do nothing when entity does not exist', () => {
const id = 20;
const value = (previous: { counter: number }) => ({
counter: previous.counter + 1,
});
class Counter extends Entity {
id = 0;
counter = 0;
pk() {
return this.id;
}

static key = 'Counter';
}
const action: SetAction = {
type: SET_TYPE,
value,
schema: Counter,
meta: {
args: [{ id }],
date: 0,
fetchedAt: 0,
expiresAt: 1000000000000,
},
};
const newState = reducer(initialState, action);
expect(newState).toBe(initialState);
});

it('set(function) should increment when it is found', () => {
const id = 20;
const value = (previous: { id: number; counter: number }) => ({
id: previous.id,
counter: previous.counter + 1,
});
class Counter extends Entity {
id = 0;
counter = 0;
pk() {
return this.id;
}

static key = 'Counter';
}
const action: SetAction = {
type: SET_TYPE,
value,
schema: Counter,
meta: {
args: [{ id }],
date: 0,
fetchedAt: 0,
expiresAt: 1000000000000,
},
};
const state = {
...initialState,
entities: {
[Counter.key]: {
[id]: { id, counter: 5 },
},
},
};
const newState = reducer(state, action);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
expect(newState.entities[Counter.key]?.[id]?.counter).toBe(6);
});

it('set should add entity when it does not exist', () => {
const id = 20;
const value = { id, title: 'hi', content: 'this is the content' };
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/state/reducer/createReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function createReducer(controller: Controller): ReducerType {
return setResponseReducer(state, action, controller);

case SET_TYPE:
return setReducer(state, action);
return setReducer(state, action, controller);

case INVALIDATEALL_TYPE:
case INVALIDATE_TYPE:
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/state/reducer/setReducer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { normalize } from '@data-client/normalizr';

import Controller from '../../controller/Controller.js';
import type { State, SetAction } from '../../types.js';

export function setReducer(state: State<unknown>, action: SetAction) {
export function setReducer(
state: State<unknown>,
action: SetAction,
controller: Controller,
) {
let value: any;
if (typeof action.value === 'function') {
const previousValue = controller.get(
action.schema,
...action.meta.args,
state,
);
if (previousValue === undefined) return state;
value = action.value(previousValue);
} else {
value = action.value;
}
try {
const { entities, indexes, entityMeta } = normalize(
action.value,
value,
action.schema,
action.meta.args as any,
state.entities,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ interface SetAction<S extends Queryable = any> {
type: typeof SET_TYPE;
schema: S;
meta: SetMeta;
value: Denormalize<S>;
value: {} | ((previousValue: Denormalize<S>) => {});
}
interface SetResponseMeta {
args: readonly any[];
Expand Down Expand Up @@ -508,7 +508,8 @@ declare class Controller<D extends GenericDispatch = DataClientDispatch> {
* Sets value for the Queryable and args.
* @see https://dataclient.io/docs/api/Controller#set
*/
set: <S extends Queryable>(schema: S, ...rest: readonly [...SchemaArgs<S>, any]) => Promise<void>;
set<S extends Queryable>(schema: S, ...rest: readonly [...SchemaArgs<S>, (previousValue: Denormalize<S>) => {}]): Promise<void>;
set<S extends Queryable>(schema: S, ...rest: readonly [...SchemaArgs<S>, {}]): Promise<void>;
/**
* Sets response for the Endpoint and args.
* @see https://dataclient.io/docs/api/Controller#setResponse
Expand Down Expand Up @@ -710,7 +711,7 @@ declare function createFetch<E extends EndpointInterface & {

declare function createSet<S extends Queryable>(schema: S, { args, fetchedAt, value, }: {
args: readonly [...SchemaArgs<S>];
value: any;
value: {} | ((previousValue: Denormalize<S>) => {});
fetchedAt?: number;
}): SetAction<S>;

Expand Down

0 comments on commit 2503402

Please sign in to comment.