Skip to content
This repository has been archived by the owner on Nov 23, 2024. It is now read-only.

Commit

Permalink
selectFromResult for mutations (#206)
Browse files Browse the repository at this point in the history
* Add selectFromResult as an option to the useMutation hook
* Update UseMutation return type and tests

Co-authored-by: Lenz Weber <[email protected]>
  • Loading branch information
msutkowski and phryneas committed Apr 18, 2021
1 parent 873bd56 commit 91e753d
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 31 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
"@rollup/plugin-replace": "^2.3.4",
"@rollup/plugin-typescript": "^8.0.0",
"@size-limit/preset-small-lib": "^4.6.0",
"@testing-library/react": "^11.1.0",
"@testing-library/react": "^11.2.6",
"@testing-library/react-hooks": "^3.4.2",
"@testing-library/user-event": "^12.2.2",
"@types/jest": "^26.0.21",
Expand Down
4 changes: 2 additions & 2 deletions src/core/apiState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,9 @@ export type MutationSubState<D extends BaseEndpointDefinition<any, any, any>> =
| (({
status: QueryStatus.fulfilled;
} & WithRequiredProp<BaseMutationSubState<D>, 'data' | 'fulfilledTimeStamp'>) & { error: undefined })
| ({
| (({
status: QueryStatus.pending;
} & BaseMutationSubState<D>)
} & BaseMutationSubState<D>) & { data?: undefined })
| ({
status: QueryStatus.rejected;
} & WithRequiredProp<BaseMutationSubState<D>, 'error'>)
Expand Down
6 changes: 5 additions & 1 deletion src/core/buildSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export type QueryResultSelectorResult<

type MutationResultSelectorFactory<Definition extends MutationDefinition<any, any, any, any>, RootState> = (
requestId: string | typeof skipSelector
) => (state: RootState) => MutationSubState<Definition> & RequestStatusFlags;
) => (state: RootState) => MutationResultSelectorResult<Definition>;

export type MutationResultSelectorResult<
Definition extends MutationDefinition<any, any, any, any>
> = MutationSubState<Definition> & RequestStatusFlags;

const initialSubState = {
status: QueryStatus.uninitialized as const,
Expand Down
52 changes: 35 additions & 17 deletions src/react-hooks/buildHooks.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import { AnyAction, createSelector, ThunkAction, ThunkDispatch } from '@reduxjs/toolkit';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
MutationSubState,
QueryStatus,
QuerySubState,
RequestStatusFlags,
SubscriptionOptions,
QueryKeys,
RootState,
} from '../core/apiState';
import { QueryStatus, QuerySubState, SubscriptionOptions, QueryKeys, RootState } from '../core/apiState';
import {
EndpointDefinitions,
MutationDefinition,
QueryDefinition,
QueryArgFrom,
ResultTypeFrom,
} from '../endpointDefinitions';
import { QueryResultSelectorResult, skipSelector } from '../core/buildSelectors';
import { QueryResultSelectorResult, MutationResultSelectorResult, skipSelector } from '../core/buildSelectors';
import { QueryActionCreatorResult, MutationActionCreatorResult } from '../core/buildInitiate';
import { shallowEqual } from '../utils';
import { Api } from '../apiTypes';
Expand All @@ -35,7 +27,7 @@ export interface QueryHooks<Definition extends QueryDefinition<any, any, any, an
}

export interface MutationHooks<Definition extends MutationDefinition<any, any, any, any, any>> {
useMutation: MutationHook<Definition>;
useMutation: UseMutation<Definition>;
}

/**
Expand Down Expand Up @@ -234,7 +226,24 @@ type UseQueryStateDefaultResult<D extends QueryDefinition<any, any, any, any>> =
status: QueryStatus;
};

export type MutationHook<D extends MutationDefinition<any, any, any, any>> = () => [
export type MutationStateSelector<R, D extends MutationDefinition<any, any, any, any>> = (
state: MutationResultSelectorResult<D>,
defaultMutationStateSelector: DefaultMutationStateSelector<D>
) => R;

export type DefaultMutationStateSelector<D extends MutationDefinition<any, any, any, any>> = (
state: MutationResultSelectorResult<D>
) => MutationResultSelectorResult<D>;

export type UseMutationStateOptions<D extends MutationDefinition<any, any, any, any>, R> = {
selectFromResult?: MutationStateSelector<R, D>;
};

export type UseMutationStateResult<_ extends MutationDefinition<any, any, any, any>, R> = NoInfer<R>;

export type UseMutation<D extends MutationDefinition<any, any, any, any>> = <R = MutationResultSelectorResult<D>>(
options?: UseMutationStateOptions<D, R>
) => [
(
arg: QueryArgFrom<D>
) => {
Expand Down Expand Up @@ -266,9 +275,11 @@ export type MutationHook<D extends MutationDefinition<any, any, any, any>> = ()
*/
unwrap: () => Promise<ResultTypeFrom<D>>;
},
MutationSubState<D> & RequestStatusFlags
UseMutationStateResult<D, R>
];

const defaultMutationStateSelector: DefaultMutationStateSelector<any> = (currentState) => currentState;

const defaultQueryStateSelector: DefaultQueryStateSelector<any> = (currentState, lastResult) => {
// data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args
const data = (currentState.isSuccess ? currentState.data : lastResult?.data) ?? currentState.data;
Expand Down Expand Up @@ -526,8 +537,8 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
};
}

function buildMutationHook(name: string): MutationHook<any> {
return () => {
function buildMutationHook(name: string): UseMutation<any> {
return ({ selectFromResult = defaultMutationStateSelector as MutationStateSelector<any, any> } = {}) => {
const { select, initiate } = api.endpoints[name] as ApiEndpointMutation<
MutationDefinition<any, any, any, any, any>,
Definitions
Expand Down Expand Up @@ -558,8 +569,15 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
[dispatch, initiate]
);

const mutationSelector = useMemo(() => select(requestId || skipSelector), [requestId, select]);
const currentState = useSelector(mutationSelector);
const mutationSelector = useMemo(
() =>
createSelector([select(requestId || skipSelector)], (subState) =>
selectFromResult(subState, defaultMutationStateSelector)
),
[select, requestId, selectFromResult]
);

const currentState = useSelector(mutationSelector, shallowEqual);

return useMemo(() => [triggerMutation, currentState], [triggerMutation, currentState]);
};
Expand Down
4 changes: 2 additions & 2 deletions src/ts41Types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MutationHook, UseLazyQuery, UseQuery } from './react-hooks/buildHooks';
import { UseMutation, UseLazyQuery, UseQuery } from './react-hooks/buildHooks';
import { DefinitionType, EndpointDefinitions, MutationDefinition, QueryDefinition } from './endpointDefinitions';

export type TS41Hooks<Definitions extends EndpointDefinitions> = keyof Definitions extends infer Keys
Expand All @@ -16,7 +16,7 @@ export type TS41Hooks<Definitions extends EndpointDefinitions> = keyof Definitio
}
: Definitions[Keys] extends { type: DefinitionType.mutation }
? {
[K in Keys as `use${Capitalize<K>}Mutation`]: MutationHook<
[K in Keys as `use${Capitalize<K>}Mutation`]: UseMutation<
Extract<Definitions[K], MutationDefinition<any, any, any, any>>
>;
}
Expand Down
130 changes: 128 additions & 2 deletions test/buildHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createApi, fetchBaseQuery, QueryStatus } from '@rtk-incubator/rtk-query
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { expectExactType, matchSequence, setupApiStore, useRenderCounter, waitMs } from './helpers';
import { actionsReducer, expectExactType, matchSequence, setupApiStore, useRenderCounter, waitMs } from './helpers';
import { server } from './mocks/server';
import { AnyAction } from 'redux';
import { SubscriptionOptions } from '@internal/core/apiState';
Expand Down Expand Up @@ -1038,7 +1038,7 @@ describe('hooks with createApi defaults set', () => {
await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'));
});

describe('selectFromResult behaviors', () => {
describe('selectFromResult (query) behaviors', () => {
let startingId = 3;
const initialPosts = [
{ id: 1, name: 'A sample post', fetched_at: new Date().toUTCString() },
Expand Down Expand Up @@ -1301,4 +1301,130 @@ describe('hooks with createApi defaults set', () => {
await waitFor(() => expect(screen.getByTestId('renderCount').textContent).toBe('3'));
});
});

describe('selectFromResult (mutation) behavior', () => {
const api = createApi({
baseQuery: async (arg: any) => {
await waitMs();
if ('amount' in arg?.body) {
amount += 1;
}
return { data: arg?.body ? { ...arg.body, ...(amount ? { amount } : {}) } : undefined };
},
endpoints: (build) => ({
increment: build.mutation<{ amount: number }, number>({
query: (amount) => ({
url: '',
method: 'POST',
body: {
amount,
},
}),
}),
}),
});

const storeRef = setupApiStore(defaultApi, {
...actionsReducer,
});

let getRenderCount: () => number = () => 0;

it('causes no more than one rerender when using selectFromResult with an empty object', async () => {
function Counter() {
const [increment] = api.endpoints.increment.useMutation({
selectFromResult: () => ({}),
});
getRenderCount = useRenderCounter();

return (
<div>
<button data-testid="incrementButton" onClick={() => increment(1)}></button>
</div>
);
}

render(<Counter />, { wrapper: storeRef.wrapper });

expect(getRenderCount()).toBe(1);

fireEvent.click(screen.getByTestId('incrementButton'));
await waitMs(200); // give our baseQuery a chance to return
expect(getRenderCount()).toBe(2);

fireEvent.click(screen.getByTestId('incrementButton'));
await waitMs(200);
expect(getRenderCount()).toBe(3);

const { increment } = api.endpoints;

const completeSequence = [
increment.matchPending,
increment.matchFulfilled,
api.internalActions.unsubscribeMutationResult.match,
increment.matchPending,
increment.matchFulfilled,
];

matchSequence(storeRef.store.getState().actions, ...completeSequence);
});

it('causes rerenders when only selected data changes', async () => {
function Counter() {
const [increment, { data }] = api.endpoints.increment.useMutation({
selectFromResult: ({ data }) => ({ data }),
});
getRenderCount = useRenderCounter();

return (
<div>
<button data-testid="incrementButton" onClick={() => increment(1)}></button>
<div data-testid="data">{JSON.stringify(data)}</div>
</div>
);
}

render(<Counter />, { wrapper: storeRef.wrapper });

expect(getRenderCount()).toBe(1);

fireEvent.click(screen.getByTestId('incrementButton'));
await waitFor(() => expect(screen.getByTestId('data').textContent).toBe(JSON.stringify({ amount: 1 })));
expect(getRenderCount()).toBe(3);

fireEvent.click(screen.getByTestId('incrementButton'));
await waitFor(() => expect(screen.getByTestId('data').textContent).toBe(JSON.stringify({ amount: 2 })));
expect(getRenderCount()).toBe(5);
});

it('causes the expected # of rerenders when NOT using selectFromResult', async () => {
function Counter() {
const [increment, data] = api.endpoints.increment.useMutation();
getRenderCount = useRenderCounter();

return (
<div>
<button data-testid="incrementButton" onClick={() => increment(1)}></button>
<div data-testid="status">{String(data.status)}</div>
</div>
);
}

render(<Counter />, { wrapper: storeRef.wrapper });

expect(getRenderCount()).toBe(1); // mount, uninitialized status in substate

fireEvent.click(screen.getByTestId('incrementButton'));

expect(getRenderCount()).toBe(2); // will be pending, isLoading: true,
await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('pending'));
await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('fulfilled'));
expect(getRenderCount()).toBe(3);

fireEvent.click(screen.getByTestId('incrementButton'));
await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('pending'));
await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('fulfilled'));
expect(getRenderCount()).toBe(5);
});
});
});
4 changes: 2 additions & 2 deletions test/unionTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ describe.skip('TS only tests', () => {
expectExactType(false as false)(result.isSuccess);
}
if (result.isLoading) {
expectExactType(undefined as string | undefined)(result.data);
expectExactType(undefined as undefined)(result.data);
expectExactType(undefined as SerializedError | FetchBaseQueryError | undefined)(result.error);

expectExactType(false as false)(result.isUninitialized);
Expand Down Expand Up @@ -373,7 +373,7 @@ describe.skip('TS only tests', () => {
expectExactType(false as false)(result.isSuccess);
}
if (result.isLoading) {
expectExactType(undefined as string | undefined)(result.data);
expectExactType(undefined as undefined)(result.data);
expectExactType(undefined as SerializedError | FetchBaseQueryError | undefined)(result.error);

expectExactType(false as false)(result.isUninitialized);
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1303,10 +1303,10 @@
"@babel/runtime" "^7.12.5"
"@types/testing-library__react-hooks" "^3.4.0"

"@testing-library/react@^11.1.0":
version "11.2.5"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.2.5.tgz#ae1c36a66c7790ddb6662c416c27863d87818eb9"
integrity sha512-yEx7oIa/UWLe2F2dqK0FtMF9sJWNXD+2PPtp39BvE0Kh9MJ9Kl0HrZAgEuhUJR+Lx8Di6Xz+rKwSdEPY2UV8ZQ==
"@testing-library/react@^11.2.6":
version "11.2.6"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.2.6.tgz#586a23adc63615985d85be0c903f374dab19200b"
integrity sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ==
dependencies:
"@babel/runtime" "^7.12.5"
"@testing-library/dom" "^7.28.1"
Expand Down

0 comments on commit 91e753d

Please sign in to comment.