diff --git a/package.json b/package.json index 9d7a505..071a46f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/apiState.ts b/src/core/apiState.ts index 3223c35..7709c33 100644 --- a/src/core/apiState.ts +++ b/src/core/apiState.ts @@ -170,9 +170,9 @@ export type MutationSubState> = | (({ status: QueryStatus.fulfilled; } & WithRequiredProp, 'data' | 'fulfilledTimeStamp'>) & { error: undefined }) - | ({ + | (({ status: QueryStatus.pending; - } & BaseMutationSubState) + } & BaseMutationSubState) & { data?: undefined }) | ({ status: QueryStatus.rejected; } & WithRequiredProp, 'error'>) diff --git a/src/core/buildSelectors.ts b/src/core/buildSelectors.ts index bd4c9e8..44b4751 100644 --- a/src/core/buildSelectors.ts +++ b/src/core/buildSelectors.ts @@ -51,7 +51,11 @@ export type QueryResultSelectorResult< type MutationResultSelectorFactory, RootState> = ( requestId: string | typeof skipSelector -) => (state: RootState) => MutationSubState & RequestStatusFlags; +) => (state: RootState) => MutationResultSelectorResult; + +export type MutationResultSelectorResult< + Definition extends MutationDefinition +> = MutationSubState & RequestStatusFlags; const initialSubState = { status: QueryStatus.uninitialized as const, diff --git a/src/react-hooks/buildHooks.ts b/src/react-hooks/buildHooks.ts index 7858a77..1da0f9b 100644 --- a/src/react-hooks/buildHooks.ts +++ b/src/react-hooks/buildHooks.ts @@ -1,14 +1,6 @@ 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, @@ -16,7 +8,7 @@ import { 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'; @@ -35,7 +27,7 @@ export interface QueryHooks> { - useMutation: MutationHook; + useMutation: UseMutation; } /** @@ -234,7 +226,24 @@ type UseQueryStateDefaultResult> = status: QueryStatus; }; -export type MutationHook> = () => [ +export type MutationStateSelector> = ( + state: MutationResultSelectorResult, + defaultMutationStateSelector: DefaultMutationStateSelector +) => R; + +export type DefaultMutationStateSelector> = ( + state: MutationResultSelectorResult +) => MutationResultSelectorResult; + +export type UseMutationStateOptions, R> = { + selectFromResult?: MutationStateSelector; +}; + +export type UseMutationStateResult<_ extends MutationDefinition, R> = NoInfer; + +export type UseMutation> = >( + options?: UseMutationStateOptions +) => [ ( arg: QueryArgFrom ) => { @@ -266,9 +275,11 @@ export type MutationHook> = () */ unwrap: () => Promise>; }, - MutationSubState & RequestStatusFlags + UseMutationStateResult ]; +const defaultMutationStateSelector: DefaultMutationStateSelector = (currentState) => currentState; + const defaultQueryStateSelector: DefaultQueryStateSelector = (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; @@ -526,8 +537,8 @@ export function buildHooks({ }; } - function buildMutationHook(name: string): MutationHook { - return () => { + function buildMutationHook(name: string): UseMutation { + return ({ selectFromResult = defaultMutationStateSelector as MutationStateSelector } = {}) => { const { select, initiate } = api.endpoints[name] as ApiEndpointMutation< MutationDefinition, Definitions @@ -558,8 +569,15 @@ export function buildHooks({ [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]); }; diff --git a/src/ts41Types.ts b/src/ts41Types.ts index 7c5e13a..34f06d2 100644 --- a/src/ts41Types.ts +++ b/src/ts41Types.ts @@ -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 = keyof Definitions extends infer Keys @@ -16,7 +16,7 @@ export type TS41Hooks = keyof Definitio } : Definitions[Keys] extends { type: DefinitionType.mutation } ? { - [K in Keys as `use${Capitalize}Mutation`]: MutationHook< + [K in Keys as `use${Capitalize}Mutation`]: UseMutation< Extract> >; } diff --git a/test/buildHooks.test.tsx b/test/buildHooks.test.tsx index 1e3158a..6953499 100644 --- a/test/buildHooks.test.tsx +++ b/test/buildHooks.test.tsx @@ -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'; @@ -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() }, @@ -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 ( +
+ +
+ ); + } + + render(, { 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 ( +
+ +
{JSON.stringify(data)}
+
+ ); + } + + render(, { 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 ( +
+ +
{String(data.status)}
+
+ ); + } + + render(, { 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); + }); + }); }); diff --git a/test/unionTypes.test.ts b/test/unionTypes.test.ts index deaad6a..6b805d9 100644 --- a/test/unionTypes.test.ts +++ b/test/unionTypes.test.ts @@ -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); @@ -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); diff --git a/yarn.lock b/yarn.lock index 05c7861..06e54f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"