diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index 6014feea47..0f80123205 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -13,7 +13,7 @@ import { QueryStatus } from './apiState' import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' import type { Api, ApiContext } from '../apiTypes' import type { ApiEndpointQuery } from './module' -import type { BaseQueryError } from '../baseQueryTypes' +import type { BaseQueryError, QueryReturnValue } from '../baseQueryTypes' import type { QueryResultSelectorResult } from './buildSelectors' declare module './module' { @@ -34,10 +34,13 @@ declare module './module' { } } +export const forceQueryFnSymbol = Symbol('forceQueryFn') + export interface StartQueryActionCreatorOptions { subscribe?: boolean forceRefetch?: boolean | number subscriptionOptions?: SubscriptionOptions + [forceQueryFnSymbol]?: () => QueryReturnValue } type StartQueryActionCreator< @@ -259,7 +262,15 @@ Features like automatic cache collection, automatic refetching etc. will not be endpointDefinition: QueryDefinition ) { const queryAction: StartQueryActionCreator = - (arg, { subscribe = true, forceRefetch, subscriptionOptions } = {}) => + ( + arg, + { + subscribe = true, + forceRefetch, + subscriptionOptions, + [forceQueryFnSymbol]: forceQueryFn, + } = {} + ) => (dispatch, getState) => { const queryCacheKey = serializeQueryArgs({ queryArgs: arg, @@ -274,6 +285,7 @@ Features like automatic cache collection, automatic refetching etc. will not be endpointName, originalArgs: arg, queryCacheKey, + [forceQueryFnSymbol]: forceQueryFn, }) const selector = ( api.endpoints[endpointName] as ApiEndpointQuery diff --git a/packages/toolkit/src/query/core/buildSlice.ts b/packages/toolkit/src/query/core/buildSlice.ts index cdfa6625f4..c9db2acbc5 100644 --- a/packages/toolkit/src/query/core/buildSlice.ts +++ b/packages/toolkit/src/query/core/buildSlice.ts @@ -157,7 +157,14 @@ export function buildSlice({ draft, meta.arg.queryCacheKey, (substate) => { - if (substate.requestId !== meta.requestId) return + if (substate.requestId !== meta.requestId) { + if ( + substate.fulfilledTimeStamp && + meta.fulfilledTimeStamp < substate.fulfilledTimeStamp + ) { + return + } + } const { merge } = definitions[ meta.arg.endpointName ] as QueryDefinition diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index e873bde369..e912922d1f 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -7,7 +7,11 @@ import type { } from '../baseQueryTypes' import type { RootState, QueryKeys, QuerySubstateIdentifier } from './apiState' import { QueryStatus } from './apiState' -import type { StartQueryActionCreatorOptions } from './buildInitiate' +import { + forceQueryFnSymbol, + StartQueryActionCreatorOptions, + QueryActionCreatorResult, +} from './buildInitiate' import type { AssertTagTypes, EndpointDefinition, @@ -144,6 +148,9 @@ function defaultTransformResponse(baseQueryReturnValue: unknown) { export type MaybeDrafted = T | Draft export type Recipe = (data: MaybeDrafted) => void | MaybeDrafted +export type UpsertRecipe = ( + data: MaybeDrafted | undefined +) => void | MaybeDrafted export type PatchQueryDataThunk< Definitions extends EndpointDefinitions, @@ -163,6 +170,24 @@ export type UpdateQueryDataThunk< updateRecipe: Recipe> ) => ThunkAction +export type UpsertQueryDataThunk< + Definitions extends EndpointDefinitions, + PartialState +> = >( + endpointName: EndpointName, + args: QueryArgFrom, + value: ResultTypeFrom +) => ThunkAction< + QueryActionCreatorResult< + Definitions[EndpointName] extends QueryDefinition + ? Definitions[EndpointName] + : never + >, + PartialState, + any, + AnyAction +> + /** * An object returned from dispatching a `api.util.updateQueryData` call. */ @@ -255,6 +280,24 @@ export function buildThunks< return ret } + const upsertQueryData: UpsertQueryDataThunk = + (endpointName, args, value) => (dispatch) => { + return dispatch( + ( + api.endpoints[endpointName] as ApiEndpointQuery< + QueryDefinition, + Definitions + > + ).initiate(args, { + subscribe: false, + forceRefetch: true, + [forceQueryFnSymbol]: () => ({ + data: value, + }), + }) + ) + } + const executeEndpoint: AsyncThunkPayloadCreator< ThunkResult, QueryThunkArg | MutationThunkArg, @@ -291,7 +334,12 @@ export function buildThunks< forced: arg.type === 'query' ? isForcedQuery(arg, getState()) : undefined, } - if (endpointDefinition.query) { + + const forceQueryFn = + arg.type === 'query' ? arg[forceQueryFnSymbol] : undefined + if (forceQueryFn) { + result = forceQueryFn() + } else if (endpointDefinition.query) { result = await baseQuery( endpointDefinition.query(arg.originalArgs), baseQueryApi, @@ -431,12 +479,17 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` const requestState = state[reducerPath]?.queries?.[arg.queryCacheKey] const fulfilledVal = requestState?.fulfilledTimeStamp - // Don't retry a request that's currently in-flight - if (requestState?.status === 'pending') return false + // Order of these checks matters. + // In order for `upsertQueryData` to successfully run while an existing request is + /// in flight, we have to check `isForcedQuery` before `status === 'pending'`, + // otherwise `queryThunk` will bail out and not run at all. // if this is forced, continue if (isForcedQuery(arg, state)) return true + // Don't retry a request that's currently in-flight + if (requestState?.status === 'pending') return false + // Pull from the cache unless we explicitly force refetch or qualify based on time if (fulfilledVal) // Value is cached and we didn't specify to refresh, skip it. @@ -527,6 +580,7 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` mutationThunk, prefetch, updateQueryData, + upsertQueryData, patchQueryData, buildMatchThunkActions, } diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index d419d8689f..c0c58582f2 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -1,7 +1,11 @@ /** * Note: this file should import all other files for type discovery and declaration merging */ -import type { PatchQueryDataThunk, UpdateQueryDataThunk } from './buildThunks' +import type { + PatchQueryDataThunk, + UpdateQueryDataThunk, + UpsertQueryDataThunk, +} from './buildThunks' import { buildThunks } from './buildThunks' import type { ActionCreatorWithPayload, @@ -210,6 +214,10 @@ declare module '../apiTypes' { Definitions, RootState > + upsertQueryData: UpsertQueryDataThunk< + Definitions, + RootState + > /** * A Redux thunk that applies a JSON diff/patch array to the cached data for a given query result. This immediately updates the Redux state with those changes. * @@ -416,6 +424,7 @@ export const coreModule = (): Module => ({ mutationThunk, patchQueryData, updateQueryData, + upsertQueryData, prefetch, buildMatchThunkActions, } = buildThunks({ @@ -444,6 +453,7 @@ export const coreModule = (): Module => ({ safeAssign(api.util, { patchQueryData, updateQueryData, + upsertQueryData, prefetch, resetApiState: sliceActions.resetApiState, }) diff --git a/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx b/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx new file mode 100644 index 0000000000..56693a1e1b --- /dev/null +++ b/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx @@ -0,0 +1,409 @@ +import { createApi } from '@reduxjs/toolkit/query/react' +import { actionsReducer, hookWaitFor, setupApiStore, waitMs } from './helpers' +import { skipToken } from '../core/buildSelectors' +import { renderHook, act, waitFor } from '@testing-library/react' + +interface Post { + id: string + title: string + contents: string +} + +const baseQuery = jest.fn() +beforeEach(() => baseQuery.mockReset()) + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const api = createApi({ + baseQuery: (...args: any[]) => { + const result = baseQuery(...args) + if (typeof result === 'object' && 'then' in result) + return result + .then((data: any) => ({ data, meta: 'meta' })) + .catch((e: any) => ({ error: e })) + return { data: result, meta: 'meta' } + }, + tagTypes: ['Post'], + endpoints: (build) => ({ + post: build.query({ + query: (id) => `post/${id}`, + providesTags: ['Post'], + }), + updatePost: build.mutation & Partial>({ + query: ({ id, ...patch }) => ({ + url: `post/${id}`, + method: 'PATCH', + body: patch, + }), + async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) { + const currentItem = api.endpoints.post.select(arg.id)(getState()) + if (currentItem?.data) { + dispatch( + api.util.upsertQueryData('post', arg.id, { + ...currentItem.data, + ...arg, + }) + ) + } + }, + invalidatesTags: (result) => (result ? ['Post'] : []), + }), + post2: build.query({ + queryFn: async (id) => { + await delay(20) + return { + data: { + id, + title: 'All about cheese.', + contents: 'TODO', + }, + } + }, + }), + }), +}) + +const storeRef = setupApiStore(api, { + ...actionsReducer, +}) + +describe('basic lifecycle', () => { + let onStart = jest.fn(), + onError = jest.fn(), + onSuccess = jest.fn() + + const extendedApi = api.injectEndpoints({ + endpoints: (build) => ({ + test: build.mutation({ + query: (x) => x, + async onQueryStarted(arg, api) { + onStart(arg) + try { + const result = await api.queryFulfilled + onSuccess(result) + } catch (e) { + onError(e) + } + }, + }), + }), + overrideExisting: true, + }) + + beforeEach(() => { + onStart.mockReset() + onError.mockReset() + onSuccess.mockReset() + }) + + test('success', async () => { + const { result } = renderHook( + () => extendedApi.endpoints.test.useMutation(), + { + wrapper: storeRef.wrapper, + } + ) + + baseQuery.mockResolvedValue('success') + + expect(onStart).not.toHaveBeenCalled() + expect(baseQuery).not.toHaveBeenCalled() + act(() => void result.current[0]('arg')) + expect(onStart).toHaveBeenCalledWith('arg') + expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined) + + expect(onError).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + await act(() => waitMs(5)) + expect(onError).not.toHaveBeenCalled() + expect(onSuccess).toHaveBeenCalledWith({ data: 'success', meta: 'meta' }) + }) + + test('error', async () => { + const { result } = renderHook( + () => extendedApi.endpoints.test.useMutation(), + { + wrapper: storeRef.wrapper, + } + ) + + baseQuery.mockRejectedValue('error') + + expect(onStart).not.toHaveBeenCalled() + expect(baseQuery).not.toHaveBeenCalled() + act(() => void result.current[0]('arg')) + expect(onStart).toHaveBeenCalledWith('arg') + expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined) + + expect(onError).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + await act(() => waitMs(5)) + expect(onError).toHaveBeenCalledWith({ + error: 'error', + isUnhandledError: false, + meta: undefined, + }) + expect(onSuccess).not.toHaveBeenCalled() + }) +}) + +describe('upsertQueryData', () => { + test('updates cache values, can apply inverse patch', async () => { + baseQuery + .mockResolvedValueOnce({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + }) + // TODO I have no idea why the query is getting called multiple times, + // but passing an additional mocked value (_any_ value) + // seems to silence some annoying "got an undefined result" logging + .mockResolvedValueOnce(42) + const { result } = renderHook(() => api.endpoints.post.useQuery('3'), { + wrapper: storeRef.wrapper, + }) + await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + const dataBefore = result.current.data + expect(dataBefore).toEqual({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + }) + + await act(async () => { + storeRef.store.dispatch( + api.util.upsertQueryData('post', '3', { + id: '3', + title: 'All about cheese.', + contents: 'I love cheese!', + }) + ) + }) + + expect(result.current.data).not.toBe(dataBefore) + expect(result.current.data).toEqual({ + id: '3', + title: 'All about cheese.', + contents: 'I love cheese!', + }) + }) + + test('does update non-existing values', async () => { + baseQuery + // throw an error to make sure there is no cached data + .mockImplementationOnce(async () => { + throw new Error('failed to load') + }) + .mockResolvedValueOnce(42) + + // a subscriber is needed to have the data stay in the cache + // Not sure if this is the wanted behaviour, I would have liked + // it to stay in the cache for the x amount of time the cache + // is preserved normally after the last subscriber was unmounted + const { result, rerender } = renderHook( + () => api.endpoints.post.useQuery('4'), + { + wrapper: storeRef.wrapper, + } + ) + await hookWaitFor(() => expect(result.current.isError).toBeTruthy()) + + // upsert the data + act(() => { + storeRef.store.dispatch( + api.util.upsertQueryData('post', '4', { + id: '4', + title: 'All about cheese', + contents: 'I love cheese!', + }) + ) + }) + + // rerender the hook + rerender() + // wait until everything has settled + await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + // the cached data is returned as the result + expect(result.current.data).toStrictEqual({ + id: '4', + title: 'All about cheese', + contents: 'I love cheese!', + }) + }) +}) + +describe('full integration', () => { + test('success case', async () => { + baseQuery + .mockResolvedValueOnce({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + }) + .mockResolvedValueOnce({ + id: '3', + title: 'Meanwhile, this changed server-side.', + contents: 'Delicious cheese!', + }) + .mockResolvedValueOnce({ + id: '3', + title: 'Meanwhile, this changed server-side.', + contents: 'Delicious cheese!', + }) + .mockResolvedValueOnce(42) + const { result } = renderHook( + () => ({ + query: api.endpoints.post.useQuery('3'), + mutation: api.endpoints.updatePost.useMutation(), + }), + { + wrapper: storeRef.wrapper, + } + ) + await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy()) + + expect(result.current.query.data).toEqual({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + }) + + await act(async () => { + await result.current.mutation[0]({ + id: '3', + contents: 'Delicious cheese!', + }) + }) + + expect(result.current.query.data).toEqual({ + id: '3', + title: 'Meanwhile, this changed server-side.', + contents: 'Delicious cheese!', + }) + + await hookWaitFor(() => + expect(result.current.query.data).toEqual({ + id: '3', + title: 'Meanwhile, this changed server-side.', + contents: 'Delicious cheese!', + }) + ) + }) + + test('error case', async () => { + baseQuery + .mockResolvedValueOnce({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + }) + .mockRejectedValueOnce('some error!') + .mockResolvedValueOnce({ + id: '3', + title: 'Meanwhile, this changed server-side.', + contents: 'TODO', + }) + .mockResolvedValueOnce(42) + + const { result } = renderHook( + () => ({ + query: api.endpoints.post.useQuery('3'), + mutation: api.endpoints.updatePost.useMutation(), + }), + { + wrapper: storeRef.wrapper, + } + ) + await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy()) + + expect(result.current.query.data).toEqual({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + }) + + await act(async () => { + await result.current.mutation[0]({ + id: '3', + contents: 'Delicious cheese!', + }) + }) + + // optimistic update + expect(result.current.query.data).toEqual({ + id: '3', + title: 'All about cheese.', + contents: 'Delicious cheese!', + }) + + // mutation failed - will not invalidate query and not refetch data from the server + await expect(() => + hookWaitFor( + () => + expect(result.current.query.data).toEqual({ + id: '3', + title: 'Meanwhile, this changed server-side.', + contents: 'TODO', + }), + 50 + ) + ).rejects.toBeTruthy() + + act(() => void result.current.query.refetch()) + + // manually refetching gives up-to-date data + await hookWaitFor( + () => + expect(result.current.query.data).toEqual({ + id: '3', + title: 'Meanwhile, this changed server-side.', + contents: 'TODO', + }), + 50 + ) + }) + + test.only('Interop with in-flight requests', async () => { + await act(async () => { + const fetchRes = storeRef.store.dispatch( + api.endpoints.post2.initiate('3') + ) + + const upsertRes = storeRef.store.dispatch( + api.util.upsertQueryData('post2', '3', { + id: '3', + title: 'Upserted title', + contents: 'Upserted contents', + }) + ) + + const selectEntry = api.endpoints.post2.select('3') + await waitFor( + () => { + const entry1 = selectEntry(storeRef.store.getState()) + expect(entry1.data).toEqual({ + id: '3', + title: 'Upserted title', + contents: 'Upserted contents', + }) + }, + { interval: 1, timeout: 15 } + ) + await waitFor( + () => { + const entry2 = selectEntry(storeRef.store.getState()) + expect(entry2.data).toEqual({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + }) + }, + { interval: 1 } + ) + }) + }) +})