diff --git a/src/blocking.ts b/src/blocking.ts index 8aa5174acf658dd15e1dd94bc4e748a5c8cdfe85..3900f0ff47fcc84c24f9f3c4ca816cde202fa5c7 100644 --- a/src/blocking.ts +++ b/src/blocking.ts @@ -70,6 +70,8 @@ export type BlockingOptimisticContext< onRestore: (cb: () => void) => void; /** Add an event listener to apply `Result` to the store. */ onSuccess: (cb: (result: Result) => void) => void; + /** Add an event listener to refetch data after mutation. */ + onRefetch: (cb: () => Promise) => void; }; interface BlockingChannel { @@ -245,6 +247,14 @@ export class BlockingMutation< } onSuccess.push(cb); }, + onRefetch(cb) { + if (expired) { + throw new Error( + "Can only call onRefetch from within the optimistic update function.", + ); + } + channel.refetches.push(cb); + }, }); } catch (error) { expired = true; diff --git a/src/debounced.ts b/src/debounced.ts index 0470fe39ee2562df3e91734beff31b1966fc0e42..da01ab2bfc991e87a5e0bdb79446f60d6b595f5f 100644 --- a/src/debounced.ts +++ b/src/debounced.ts @@ -72,7 +72,9 @@ export interface DebouncedMutationOptions< /** * Refetch all of the data this mutation could have affected. */ - refetch?: () => Promise; + refetch?: ( + context: Config["context"] & { args: NoInfer }, + ) => Promise; } export type DebouncedOptimisticContext = @@ -501,7 +503,10 @@ export class DebouncedMutation< this.#notify(channel, "refetching", result); // Call refetch and all refetch callbacks in parallel Promise.allSettled([ - this.#options.refetch?.(), + this.#options.refetch?.({ + ...this.#client.context, + args: firstArgs, + }), ...refetchCallbacks.map((cb) => cb()), ]).then((results) => { // Report any errors from refetch or callbacks diff --git a/src/tanstack-query.ts b/src/tanstack-query.ts index 1a8be40fedff811f56cdb350e4afb43ff3669fe4..76375c7dd7f82d006d82eeea4f5e745e7375b95c 100644 --- a/src/tanstack-query.ts +++ b/src/tanstack-query.ts @@ -49,17 +49,7 @@ class TanstackQueryOptimisticHelpers { if (result) { this.#client.cancelQueries({ queryKey: query.queryKey, exact: true }) .catch(() => {}); - const state = this.#client.getQueryCache().find({ - queryKey: query.queryKey, - exact: true, - }); - if (!state || this.#refetchHashes.includes(state.queryHash)) return; - this.#refetchHashes.push(state.queryHash); - try { - this.#onRefetch(async () => { - await this.#client.refetchQueries(query); - }); - } catch { /* Ignore expiry */ } + this.refetchOnSettled(query); } } @@ -111,6 +101,25 @@ class TanstackQueryOptimisticHelpers { }); } + /** + * Mark a query as changed and schedule a refetch. + * Does not modify any data. + * If the query doesn't exist, the updater is skipped. + */ + refetchOnSettled(queryKey: QueryKeyAndFn) { + const state = this.#client.getQueryCache().find({ + queryKey: queryKey.queryKey, + exact: true, + }); + if (!state || this.#refetchHashes.includes(state.queryHash)) return; + this.#refetchHashes.push(state.queryHash); + try { + this.#onRefetch(async () => { + await this.#client.refetchQueries(queryKey); + }); + } catch { /* Ignore expiry */ } + } + /** * Set a property at an object path. * If the query or path doesn't exist, the updater is skipped. @@ -413,7 +422,7 @@ class TanstackQueryOptimisticHelpers { * Push item(s) to the end of an array at an object path. * If the query or path doesn't exist, the updater is skipped. */ - arrayPush(queryKey: QueryKeyAndFn, ...items: Data[]) { + arrayPush(queryKey: QueryKeyAndFn, ...items: Data[]) { const prev = this.#get(queryKey); if (!prev || !Array.isArray(prev)) return; @@ -431,7 +440,7 @@ class TanstackQueryOptimisticHelpers { * Add item(s) to the beginning of an array at an object path. * If the query or path doesn't exist, the updater is skipped. */ - arrayUnshift(queryKey: QueryKeyAndFn, ...items: Data[]) { + arrayUnshift(queryKey: QueryKeyAndFn, ...items: Data[]) { const prev = this.#get(queryKey); if (!prev || !Array.isArray(prev)) return; @@ -450,7 +459,7 @@ class TanstackQueryOptimisticHelpers { * If the query or path doesn't exist, the updater is skipped. */ arrayRemove( - queryKey: QueryKeyAndFn, + queryKey: QueryKeyAndFn, filter: ( item: Data, index: number, @@ -472,7 +481,7 @@ class TanstackQueryOptimisticHelpers { * If the query or path doesn't exist, the updater is skipped. */ arrayUpdate( - queryKey: QueryKeyAndFn, + queryKey: QueryKeyAndFn, { filter, update, @@ -499,7 +508,7 @@ class TanstackQueryOptimisticHelpers { * If the query or path doesn't exist, the updater is skipped. */ arrayInsertIndex( - queryKey: QueryKeyAndFn, + queryKey: QueryKeyAndFn, index: number, ...items: Data[] ) { diff --git a/test/tanstack-query-helpers.test.ts b/test/tanstack-query-helpers.test.ts index 6096e1a087dec62c28dd1eb8c0ffd2f007cccfc9..5599a99295c9f7f584380056c2a17772418cec1b 100644 --- a/test/tanstack-query-helpers.test.ts +++ b/test/tanstack-query-helpers.test.ts @@ -779,6 +779,77 @@ test("removeQuery - should skip if query doesn't exist", () => { assertEquals(restoreFns.length, 0); }); +// ============================================================================ +// refetchOnSettled() tests +// ============================================================================ + +test("refetchOnSettled - should schedule a refetch without modifying data", () => { + const { client, queryTest } = createTestQueryClient(); + const refetchFns: Array<() => void> = []; + const restoreFns: Array<() => void> = []; + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: (fn) => restoreFns.push(fn), + onRefetch: (fn) => refetchFns.push(fn), + }); + + const originalData = client.getQueryData(queryTest.queryKey); + + helpers.refetchOnSettled(queryTest); + + const result = client.getQueryData(queryTest.queryKey); + // Data should not be modified + assertEquals(result, originalData); + + // A refetch should have been scheduled + assertEquals(refetchFns.length, 1); + + // No restore callbacks should be registered + assertEquals(restoreFns.length, 0); +}); + +test("refetchOnSettled - should skip if query doesn't exist", () => { + const { client } = createTestQueryClient(); + const refetchFns: Array<() => void> = []; + const restoreFns: Array<() => void> = []; + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: (fn) => restoreFns.push(fn), + onRefetch: (fn) => refetchFns.push(fn), + }); + + const queryNonexistent = queryOptions({ + queryKey: ["nonexistent"], + queryFn: (): TestData => initialData, + }); + + helpers.refetchOnSettled(queryNonexistent); + + // No refetch should be scheduled for non-existent query + assertEquals(refetchFns.length, 0); + assertEquals(restoreFns.length, 0); +}); + +test("refetchOnSettled - should deduplicate refetches for the same query", () => { + const { client, queryTest } = createTestQueryClient(); + const refetchFns: Array<() => void> = []; + const restoreFns: Array<() => void> = []; + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: (fn) => restoreFns.push(fn), + onRefetch: (fn) => refetchFns.push(fn), + }); + + // Call refetchOnSettled multiple times on the same query + helpers.refetchOnSettled(queryTest); + helpers.refetchOnSettled(queryTest); + helpers.refetchOnSettled(queryTest); + + // Only one refetch should be scheduled (deduplicated by query hash) + assertEquals(refetchFns.length, 1); + assertEquals(restoreFns.length, 0); +}); + // ============================================================================ // Integration tests // ============================================================================