| author | |
| committer | |
| log | c384cb9c7ac09948889010176f298fb775481b4b |
| tree | 1569b005e34ffedbe1cb6c8cc5a70bdfd396cb5f |
| parent | 2486cbb9d095be3d97251f02f076d0e2a1cb0033 |
| signature |
4 files changed, 113 insertions(+), 18 deletions(-)
src/blocking.ts+10| ... | ... | @@ -70,6 +70,8 @@ export type BlockingOptimisticContext< |
| 70 | 70 | onRestore: (cb: () => void) => void; |
| 71 | 71 | /** Add an event listener to apply `Result` to the store. */ |
| 72 | 72 | onSuccess: (cb: (result: Result) => void) => void; |
| 73 | /** Add an event listener to refetch data after mutation. */ | |
| 74 | onRefetch: (cb: () => Promise<void>) => void; | |
| 73 | 75 | }; |
| 74 | 76 | |
| 75 | 77 | interface BlockingChannel<Args extends unknown[], Result, OptimisticHelpers> { |
| ... | ... | @@ -245,6 +247,14 @@ export class BlockingMutation< |
| 245 | 247 | } |
| 246 | 248 | onSuccess.push(cb); |
| 247 | 249 | }, |
| 250 | onRefetch(cb) { | |
| 251 | if (expired) { | |
| 252 | throw new Error( | |
| 253 | "Can only call onRefetch from within the optimistic update function.", | |
| 254 | ); | |
| 255 | } | |
| 256 | channel.refetches.push(cb); | |
| 257 | }, | |
| 248 | 258 | }); |
| 249 | 259 | } catch (error) { |
| 250 | 260 | expired = true; |
src/debounced.ts+7-2| ... | ... | @@ -72,7 +72,9 @@ export interface DebouncedMutationOptions< |
| 72 | 72 | /** |
| 73 | 73 | * Refetch all of the data this mutation could have affected. |
| 74 | 74 | */ |
| 75 | refetch?: () => Promise<void>; | |
| 75 | refetch?: ( | |
| 76 | context: Config["context"] & { args: NoInfer<Args> }, | |
| 77 | ) => Promise<void>; | |
| 76 | 78 | } |
| 77 | 79 | |
| 78 | 80 | export type DebouncedOptimisticContext<Config extends MutationClientConfig> = |
| ... | ... | @@ -501,7 +503,10 @@ export class DebouncedMutation< |
| 501 | 503 | this.#notify(channel, "refetching", result); |
| 502 | 504 | // Call refetch and all refetch callbacks in parallel |
| 503 | 505 | Promise.allSettled([ |
| 504 | this.#options.refetch?.(), | |
| 506 | this.#options.refetch?.({ | |
| 507 | ...this.#client.context, | |
| 508 | args: firstArgs, | |
| 509 | }), | |
| 505 | 510 | ...refetchCallbacks.map((cb) => cb()), |
| 506 | 511 | ]).then((results) => { |
| 507 | 512 | // Report any errors from refetch or callbacks |
src/tanstack-query.ts+25-16| ... | ... | @@ -49,17 +49,7 @@ class TanstackQueryOptimisticHelpers { |
| 49 | 49 | if (result) { |
| 50 | 50 | this.#client.cancelQueries({ queryKey: query.queryKey, exact: true }) |
| 51 | 51 | .catch(() => {}); |
| 52 | const state = this.#client.getQueryCache().find({ | |
| 53 | queryKey: query.queryKey, | |
| 54 | exact: true, | |
| 55 | }); | |
| 56 | if (!state || this.#refetchHashes.includes(state.queryHash)) return; | |
| 57 | this.#refetchHashes.push(state.queryHash); | |
| 58 | try { | |
| 59 | this.#onRefetch(async () => { | |
| 60 | await this.#client.refetchQueries(query); | |
| 61 | }); | |
| 62 | } catch { /* Ignore expiry */ } | |
| 52 | this.refetchOnSettled(query); | |
| 63 | 53 | } |
| 64 | 54 | } |
| 65 | 55 | |
| ... | ... | @@ -111,6 +101,25 @@ class TanstackQueryOptimisticHelpers { |
| 111 | 101 | }); |
| 112 | 102 | } |
| 113 | 103 | |
| 104 | /** | |
| 105 | * Mark a query as changed and schedule a refetch. | |
| 106 | * Does not modify any data. | |
| 107 | * If the query doesn't exist, the updater is skipped. | |
| 108 | */ | |
| 109 | refetchOnSettled(queryKey: QueryKeyAndFn) { | |
| 110 | const state = this.#client.getQueryCache().find({ | |
| 111 | queryKey: queryKey.queryKey, | |
| 112 | exact: true, | |
| 113 | }); | |
| 114 | if (!state || this.#refetchHashes.includes(state.queryHash)) return; | |
| 115 | this.#refetchHashes.push(state.queryHash); | |
| 116 | try { | |
| 117 | this.#onRefetch(async () => { | |
| 118 | await this.#client.refetchQueries(queryKey); | |
| 119 | }); | |
| 120 | } catch { /* Ignore expiry */ } | |
| 121 | } | |
| 122 | ||
| 114 | 123 | /** |
| 115 | 124 | * Set a property at an object path. |
| 116 | 125 | * If the query or path doesn't exist, the updater is skipped. |
| ... | ... | @@ -413,7 +422,7 @@ class TanstackQueryOptimisticHelpers { |
| 413 | 422 | * Push item(s) to the end of an array at an object path. |
| 414 | 423 | * If the query or path doesn't exist, the updater is skipped. |
| 415 | 424 | */ |
| 416 | arrayPush<Data>(queryKey: QueryKeyAndFn<Data[]>, ...items: Data[]) { | |
| 425 | arrayPush<Data>(queryKey: QueryKeyAndFn<Data[] | null>, ...items: Data[]) { | |
| 417 | 426 | const prev = this.#get(queryKey); |
| 418 | 427 | if (!prev || !Array.isArray(prev)) return; |
| 419 | 428 | |
| ... | ... | @@ -431,7 +440,7 @@ class TanstackQueryOptimisticHelpers { |
| 431 | 440 | * Add item(s) to the beginning of an array at an object path. |
| 432 | 441 | * If the query or path doesn't exist, the updater is skipped. |
| 433 | 442 | */ |
| 434 | arrayUnshift<Data>(queryKey: QueryKeyAndFn<Data[]>, ...items: Data[]) { | |
| 443 | arrayUnshift<Data>(queryKey: QueryKeyAndFn<Data[] | null>, ...items: Data[]) { | |
| 435 | 444 | const prev = this.#get(queryKey); |
| 436 | 445 | if (!prev || !Array.isArray(prev)) return; |
| 437 | 446 | |
| ... | ... | @@ -450,7 +459,7 @@ class TanstackQueryOptimisticHelpers { |
| 450 | 459 | * If the query or path doesn't exist, the updater is skipped. |
| 451 | 460 | */ |
| 452 | 461 | arrayRemove<Data>( |
| 453 | queryKey: QueryKeyAndFn<Data[]>, | |
| 462 | queryKey: QueryKeyAndFn<Data[] | null>, | |
| 454 | 463 | filter: ( |
| 455 | 464 | item: Data, |
| 456 | 465 | index: number, |
| ... | ... | @@ -472,7 +481,7 @@ class TanstackQueryOptimisticHelpers { |
| 472 | 481 | * If the query or path doesn't exist, the updater is skipped. |
| 473 | 482 | */ |
| 474 | 483 | arrayUpdate<Data>( |
| 475 | queryKey: QueryKeyAndFn<Data[]>, | |
| 484 | queryKey: QueryKeyAndFn<Data[] | null>, | |
| 476 | 485 | { |
| 477 | 486 | filter, |
| 478 | 487 | update, |
| ... | ... | @@ -499,7 +508,7 @@ class TanstackQueryOptimisticHelpers { |
| 499 | 508 | * If the query or path doesn't exist, the updater is skipped. |
| 500 | 509 | */ |
| 501 | 510 | arrayInsertIndex<Data>( |
| 502 | queryKey: QueryKeyAndFn<Data[]>, | |
| 511 | queryKey: QueryKeyAndFn<Data[] | null>, | |
| 503 | 512 | index: number, |
| 504 | 513 | ...items: Data[] |
| 505 | 514 | ) { |
test/tanstack-query-helpers.test.ts+71| ... | ... | @@ -779,6 +779,77 @@ test("removeQuery - should skip if query doesn't exist", () => { |
| 779 | 779 | assertEquals(restoreFns.length, 0); |
| 780 | 780 | }); |
| 781 | 781 | |
| 782 | // ============================================================================ | |
| 783 | // refetchOnSettled() tests | |
| 784 | // ============================================================================ | |
| 785 | ||
| 786 | test("refetchOnSettled - should schedule a refetch without modifying data", () => { | |
| 787 | const { client, queryTest } = createTestQueryClient(); | |
| 788 | const refetchFns: Array<() => void> = []; | |
| 789 | const restoreFns: Array<() => void> = []; | |
| 790 | ||
| 791 | const helpers = queryClientOptimisticHelpers(client)({ | |
| 792 | onRestore: (fn) => restoreFns.push(fn), | |
| 793 | onRefetch: (fn) => refetchFns.push(fn), | |
| 794 | }); | |
| 795 | ||
| 796 | const originalData = client.getQueryData<TestData>(queryTest.queryKey); | |
| 797 | ||
| 798 | helpers.refetchOnSettled(queryTest); | |
| 799 | ||
| 800 | const result = client.getQueryData<TestData>(queryTest.queryKey); | |
| 801 | // Data should not be modified | |
| 802 | assertEquals(result, originalData); | |
| 803 | ||
| 804 | // A refetch should have been scheduled | |
| 805 | assertEquals(refetchFns.length, 1); | |
| 806 | ||
| 807 | // No restore callbacks should be registered | |
| 808 | assertEquals(restoreFns.length, 0); | |
| 809 | }); | |
| 810 | ||
| 811 | test("refetchOnSettled - should skip if query doesn't exist", () => { | |
| 812 | const { client } = createTestQueryClient(); | |
| 813 | const refetchFns: Array<() => void> = []; | |
| 814 | const restoreFns: Array<() => void> = []; | |
| 815 | ||
| 816 | const helpers = queryClientOptimisticHelpers(client)({ | |
| 817 | onRestore: (fn) => restoreFns.push(fn), | |
| 818 | onRefetch: (fn) => refetchFns.push(fn), | |
| 819 | }); | |
| 820 | ||
| 821 | const queryNonexistent = queryOptions({ | |
| 822 | queryKey: ["nonexistent"], | |
| 823 | queryFn: (): TestData => initialData, | |
| 824 | }); | |
| 825 | ||
| 826 | helpers.refetchOnSettled(queryNonexistent); | |
| 827 | ||
| 828 | // No refetch should be scheduled for non-existent query | |
| 829 | assertEquals(refetchFns.length, 0); | |
| 830 | assertEquals(restoreFns.length, 0); | |
| 831 | }); | |
| 832 | ||
| 833 | test("refetchOnSettled - should deduplicate refetches for the same query", () => { | |
| 834 | const { client, queryTest } = createTestQueryClient(); | |
| 835 | const refetchFns: Array<() => void> = []; | |
| 836 | const restoreFns: Array<() => void> = []; | |
| 837 | ||
| 838 | const helpers = queryClientOptimisticHelpers(client)({ | |
| 839 | onRestore: (fn) => restoreFns.push(fn), | |
| 840 | onRefetch: (fn) => refetchFns.push(fn), | |
| 841 | }); | |
| 842 | ||
| 843 | // Call refetchOnSettled multiple times on the same query | |
| 844 | helpers.refetchOnSettled(queryTest); | |
| 845 | helpers.refetchOnSettled(queryTest); | |
| 846 | helpers.refetchOnSettled(queryTest); | |
| 847 | ||
| 848 | // Only one refetch should be scheduled (deduplicated by query hash) | |
| 849 | assertEquals(refetchFns.length, 1); | |
| 850 | assertEquals(restoreFns.length, 0); | |
| 851 | }); | |
| 852 | ||
| 782 | 853 | // ============================================================================ |
| 783 | 854 | // Integration tests |
| 784 | 855 | // ============================================================================ |