authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 18:08:10-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 18:51:56-08:00
logc384cb9c7ac09948889010176f298fb775481b4b
tree1569b005e34ffedbe1cb6c8cc5a70bdfd396cb5f
parent2486cbb9d095be3d97251f02f076d0e2a1cb0033
signaturelock-open Commit is signed but in an unrecognized format.

feat: some more things


4 files changed, 113 insertions(+), 18 deletions(-)

src/blocking.ts+10
...@@ -70,6 +70,8 @@ export type BlockingOptimisticContext<...@@ -70,6 +70,8 @@ export type BlockingOptimisticContext<
70 onRestore: (cb: () => void) => void;70 onRestore: (cb: () => void) => void;
71 /** Add an event listener to apply `Result` to the store. */71 /** Add an event listener to apply `Result` to the store. */
72 onSuccess: (cb: (result: Result) => void) => void;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};
7476
75interface BlockingChannel<Args extends unknown[], Result, OptimisticHelpers> {77interface BlockingChannel<Args extends unknown[], Result, OptimisticHelpers> {
...@@ -245,6 +247,14 @@ export class BlockingMutation<...@@ -245,6 +247,14 @@ export class BlockingMutation<
245 }247 }
246 onSuccess.push(cb);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 } catch (error) {259 } catch (error) {
250 expired = true;260 expired = true;
src/debounced.ts+7-2
...@@ -72,7 +72,9 @@ export interface DebouncedMutationOptions<...@@ -72,7 +72,9 @@ export interface DebouncedMutationOptions<
72 /**72 /**
73 * Refetch all of the data this mutation could have affected.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}
7779
78export type DebouncedOptimisticContext<Config extends MutationClientConfig> =80export type DebouncedOptimisticContext<Config extends MutationClientConfig> =
...@@ -501,7 +503,10 @@ export class DebouncedMutation<...@@ -501,7 +503,10 @@ export class DebouncedMutation<
501 this.#notify(channel, "refetching", result);503 this.#notify(channel, "refetching", result);
502 // Call refetch and all refetch callbacks in parallel504 // Call refetch and all refetch callbacks in parallel
503 Promise.allSettled([505 Promise.allSettled([
504 this.#options.refetch?.(),506 this.#options.refetch?.({
507 ...this.#client.context,
508 args: firstArgs,
509 }),
505 ...refetchCallbacks.map((cb) => cb()),510 ...refetchCallbacks.map((cb) => cb()),
506 ]).then((results) => {511 ]).then((results) => {
507 // Report any errors from refetch or callbacks512 // Report any errors from refetch or callbacks
src/tanstack-query.ts+25-16
...@@ -49,17 +49,7 @@ class TanstackQueryOptimisticHelpers {...@@ -49,17 +49,7 @@ class TanstackQueryOptimisticHelpers {
49 if (result) {49 if (result) {
50 this.#client.cancelQueries({ queryKey: query.queryKey, exact: true })50 this.#client.cancelQueries({ queryKey: query.queryKey, exact: true })
51 .catch(() => {});51 .catch(() => {});
52 const state = this.#client.getQueryCache().find({52 this.refetchOnSettled(query);
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 */ }
63 }53 }
64 }54 }
6555
...@@ -111,6 +101,25 @@ class TanstackQueryOptimisticHelpers {...@@ -111,6 +101,25 @@ class TanstackQueryOptimisticHelpers {
111 });101 });
112 }102 }
113103
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 * Set a property at an object path.124 * Set a property at an object path.
116 * If the query or path doesn't exist, the updater is skipped.125 * If the query or path doesn't exist, the updater is skipped.
...@@ -413,7 +422,7 @@ class TanstackQueryOptimisticHelpers {...@@ -413,7 +422,7 @@ class TanstackQueryOptimisticHelpers {
413 * Push item(s) to the end of an array at an object path.422 * Push item(s) to the end of an array at an object path.
414 * If the query or path doesn't exist, the updater is skipped.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 const prev = this.#get(queryKey);426 const prev = this.#get(queryKey);
418 if (!prev || !Array.isArray(prev)) return;427 if (!prev || !Array.isArray(prev)) return;
419428
...@@ -431,7 +440,7 @@ class TanstackQueryOptimisticHelpers {...@@ -431,7 +440,7 @@ class TanstackQueryOptimisticHelpers {
431 * Add item(s) to the beginning of an array at an object path.440 * Add item(s) to the beginning of an array at an object path.
432 * If the query or path doesn't exist, the updater is skipped.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 const prev = this.#get(queryKey);444 const prev = this.#get(queryKey);
436 if (!prev || !Array.isArray(prev)) return;445 if (!prev || !Array.isArray(prev)) return;
437446
...@@ -450,7 +459,7 @@ class TanstackQueryOptimisticHelpers {...@@ -450,7 +459,7 @@ class TanstackQueryOptimisticHelpers {
450 * If the query or path doesn't exist, the updater is skipped.459 * If the query or path doesn't exist, the updater is skipped.
451 */460 */
452 arrayRemove<Data>(461 arrayRemove<Data>(
453 queryKey: QueryKeyAndFn<Data[]>,462 queryKey: QueryKeyAndFn<Data[] | null>,
454 filter: (463 filter: (
455 item: Data,464 item: Data,
456 index: number,465 index: number,
...@@ -472,7 +481,7 @@ class TanstackQueryOptimisticHelpers {...@@ -472,7 +481,7 @@ class TanstackQueryOptimisticHelpers {
472 * If the query or path doesn't exist, the updater is skipped.481 * If the query or path doesn't exist, the updater is skipped.
473 */482 */
474 arrayUpdate<Data>(483 arrayUpdate<Data>(
475 queryKey: QueryKeyAndFn<Data[]>,484 queryKey: QueryKeyAndFn<Data[] | null>,
476 {485 {
477 filter,486 filter,
478 update,487 update,
...@@ -499,7 +508,7 @@ class TanstackQueryOptimisticHelpers {...@@ -499,7 +508,7 @@ class TanstackQueryOptimisticHelpers {
499 * If the query or path doesn't exist, the updater is skipped.508 * If the query or path doesn't exist, the updater is skipped.
500 */509 */
501 arrayInsertIndex<Data>(510 arrayInsertIndex<Data>(
502 queryKey: QueryKeyAndFn<Data[]>,511 queryKey: QueryKeyAndFn<Data[] | null>,
503 index: number,512 index: number,
504 ...items: Data[]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,6 +779,77 @@ test("removeQuery - should skip if query doesn't exist", () => {
779 assertEquals(restoreFns.length, 0);779 assertEquals(restoreFns.length, 0);
780});780});
781781
782// ============================================================================
783// refetchOnSettled() tests
784// ============================================================================
785
786test("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
811test("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
833test("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// Integration tests854// Integration tests
784// ============================================================================855// ============================================================================