1import { assertEquals } from "@std/assert";
2import { QueryClient, queryOptions } from "@tanstack/react-query";
3import { test } from "vitest";
4import { queryClientOptimisticHelpers, reactiveFromQueryCache } from "../src/tanstack-query.ts";
5
6interface TestData {
7 name: string;
8 count: number;
9 active: boolean;
10 settings: {
11 theme: string;
12 notifications: boolean;
13 };
14 items: Array<{ id: number; label: string }>;
15 tags: string[];
16}
17
18interface TestArrayItem {
19 id: number;
20 label: string;
21}
22
23const initialData: TestData = {
24 name: "Test",
25 count: 10,
26 active: true,
27 settings: {
28 theme: "dark",
29 notifications: true,
30 },
31 items: [
32 { id: 1, label: "first" },
33 { id: 2, label: "second" },
34 { id: 3, label: "third" },
35 ],
36 tags: ["alpha", "beta", "gamma"],
37};
38
39const initialArrayItems: TestArrayItem[] = [
40 { id: 1, label: "first" },
41 { id: 2, label: "second" },
42 { id: 3, label: "third" },
43];
44
45function createTestQueryClient() {
46 const client = new QueryClient({
47 defaultOptions: { queries: { retry: false } },
48 });
49
50 const queryTest = queryOptions({
51 queryKey: ["test-data"],
52 queryFn: (): TestData => initialData,
53 });
54
55 client.setQueryData(queryTest.queryKey, structuredClone(initialData));
56
57 return { client, queryTest };
58}
59
60function createArrayQueryClient() {
61 const client = new QueryClient({
62 defaultOptions: { queries: { retry: false } },
63 });
64
65 const queryArray = queryOptions({
66 queryKey: ["test-array"],
67 queryFn: (): TestArrayItem[] => initialArrayItems,
68 });
69
70 client.setQueryData(queryArray.queryKey, structuredClone(initialArrayItems));
71
72 return { client, queryArray };
73}
74
75test("helpers can be spread and retain bound this", () => {
76 const { client, queryTest } = createTestQueryClient();
77
78 const helpers = queryClientOptimisticHelpers(client)({
79 onRestore: () => {},
80 onRefetch: () => {},
81 });
82 const spreadHelpers = { ...helpers };
83
84 assertEquals(Object.keys(spreadHelpers).includes("objIncrement"), true);
85
86 spreadHelpers.objIncrement(queryTest, ["count"], 2);
87
88 const result = client.getQueryData<TestData>(queryTest.queryKey);
89 assertEquals(result?.count, 12);
90});
91
92test("reactiveFromQueryCache derives and subscribes to query data", () => {
93 const { client, queryTest } = createTestQueryClient();
94 const reactive = reactiveFromQueryCache(
95 client,
96 queryTest,
97 (data) => data ? { name: data.name } : null,
98 );
99 let changes = 0;
100 const unsubscribe = reactive.sub(() => changes += 1);
101
102 assertEquals(reactive.get(), { name: "Test" });
103
104 client.setQueryData(queryTest.queryKey, { ...initialData, name: "Updated" });
105 assertEquals(reactive.get(), { name: "Updated" });
106 assertEquals(changes, 1);
107
108 unsubscribe();
109 client.setQueryData(queryTest.queryKey, { ...initialData, name: "Ignored" });
110 assertEquals(changes, 1);
111});
112
113// ============================================================================
114// set() tests
115// ============================================================================
116
117test("set - should set entire query data and create if doesn't exist", () => {
118 const { client } = createTestQueryClient();
119 const restoreFns: Array<() => void> = [];
120
121 const helpers = queryClientOptimisticHelpers(client)({
122 onRestore: (fn) => restoreFns.push(fn),
123 onRefetch: () => {},
124 });
125
126 const queryNew = queryOptions({
127 queryKey: ["new-query"],
128 queryFn: (): TestData => initialData,
129 });
130
131 const newData: TestData = {
132 name: "Created",
133 count: 99,
134 active: false,
135 settings: { theme: "light", notifications: false },
136 items: [],
137 tags: [],
138 };
139
140 helpers.set(queryNew, newData);
141
142 const result = client.getQueryData<TestData>(queryNew.queryKey);
143 assertEquals(result, newData);
144
145 // Restore should remove it since it didn't exist before
146 restoreFns[0]();
147 const restored = client.getQueryData<TestData>(queryNew.queryKey);
148 assertEquals(restored, undefined);
149});
150
151test("set - should update existing query data", () => {
152 const { client, queryTest } = createTestQueryClient();
153 const restoreFns: Array<() => void> = [];
154
155 const helpers = queryClientOptimisticHelpers(client)({
156 onRestore: (fn) => restoreFns.push(fn),
157 onRefetch: () => {},
158 });
159
160 const newData: TestData = {
161 name: "Updated",
162 count: 99,
163 active: false,
164 settings: { theme: "light", notifications: false },
165 items: [],
166 tags: [],
167 };
168
169 helpers.set(queryTest, newData);
170
171 const result = client.getQueryData<TestData>(queryTest.queryKey);
172 assertEquals(result, newData);
173
174 // Restore should revert to original
175 restoreFns[0]();
176 const restored = client.getQueryData<TestData>(queryTest.queryKey);
177 assertEquals(restored?.name, "Test");
178 assertEquals(restored?.count, 10);
179});
180
181test("set - should accept updater function", () => {
182 const { client, queryTest } = createTestQueryClient();
183
184 const helpers = queryClientOptimisticHelpers(client)({
185 onRestore: () => {},
186 onRefetch: () => {},
187 });
188
189 helpers.set(queryTest, (prev) => prev ? { ...prev, name: "Updated" } : prev);
190
191 const result = client.getQueryData<TestData>(queryTest.queryKey);
192 assertEquals(result?.name, "Updated");
193 assertEquals(result?.count, 10); // Other fields unchanged
194});
195
196// ============================================================================
197// updateExisting() tests
198// ============================================================================
199
200test("updateExisting - should update existing query data", () => {
201 const { client, queryTest } = createTestQueryClient();
202 const restoreFns: Array<() => void> = [];
203
204 const helpers = queryClientOptimisticHelpers(client)({
205 onRestore: (fn) => restoreFns.push(fn),
206 onRefetch: () => {},
207 });
208
209 const newData: TestData = {
210 name: "Updated",
211 count: 99,
212 active: false,
213 settings: { theme: "light", notifications: false },
214 items: [],
215 tags: [],
216 };
217
218 helpers.updateExisting(queryTest, newData);
219
220 const result = client.getQueryData<TestData>(queryTest.queryKey);
221 assertEquals(result, newData);
222
223 // Restore should revert to original
224 restoreFns[0]();
225 const restored = client.getQueryData<TestData>(queryTest.queryKey);
226 assertEquals(restored?.name, "Test");
227});
228
229test("updateExisting - should skip if query doesn't exist", () => {
230 const { client } = createTestQueryClient();
231 const restoreFns: Array<() => void> = [];
232
233 const helpers = queryClientOptimisticHelpers(client)({
234 onRestore: (fn) => restoreFns.push(fn),
235 onRefetch: () => {},
236 });
237
238 const queryNonexistent = queryOptions({
239 queryKey: ["nonexistent"],
240 queryFn: (): TestData => initialData,
241 });
242
243 helpers.updateExisting(queryNonexistent, { name: "test" } as any);
244
245 assertEquals(restoreFns.length, 0);
246});
247
248// ============================================================================
249// objSet() tests
250// ============================================================================
251
252test("objSet - should set property at path", () => {
253 const { client, queryTest } = createTestQueryClient();
254 const restoreFns: Array<() => void> = [];
255
256 const helpers = queryClientOptimisticHelpers(client)({
257 onRestore: (fn) => restoreFns.push(fn),
258 onRefetch: () => {},
259 });
260
261 helpers.objSet(queryTest, ["name"], "NewName");
262
263 const result = client.getQueryData<TestData>(queryTest.queryKey);
264 assertEquals(result?.name, "NewName");
265 assertEquals(result?.count, 10);
266
267 // Restore
268 restoreFns[0]();
269 const restored = client.getQueryData<TestData>(queryTest.queryKey);
270 assertEquals(restored?.name, "Test");
271});
272
273test("objSet - should set nested property", () => {
274 const { client, queryTest } = createTestQueryClient();
275 const restoreFns: Array<() => void> = [];
276
277 const helpers = queryClientOptimisticHelpers(client)({
278 onRestore: (fn) => restoreFns.push(fn),
279 onRefetch: () => {},
280 });
281
282 helpers.objSet(queryTest, ["settings", "theme"], "light");
283
284 const result = client.getQueryData<TestData>(queryTest.queryKey);
285 assertEquals(result?.settings.theme, "light");
286 assertEquals(result?.settings.notifications, true);
287
288 // Restore
289 restoreFns[0]();
290 const restored = client.getQueryData<TestData>(queryTest.queryKey);
291 assertEquals(restored?.settings.theme, "dark");
292});
293
294test("objSet - should accept updater function", () => {
295 const { client, queryTest } = createTestQueryClient();
296
297 const helpers = queryClientOptimisticHelpers(client)({
298 onRestore: () => {},
299 onRefetch: () => {},
300 });
301
302 helpers.objSet(queryTest, ["count"], (n) => n + 5);
303
304 const result = client.getQueryData<TestData>(queryTest.queryKey);
305 assertEquals(result?.count, 15);
306});
307
308// ============================================================================
309// objIncrement() tests
310// ============================================================================
311
312test("objIncrement - should increment by 1 by default", () => {
313 const { client, queryTest } = createTestQueryClient();
314 const restoreFns: Array<() => void> = [];
315
316 const helpers = queryClientOptimisticHelpers(client)({
317 onRestore: (fn) => restoreFns.push(fn),
318 onRefetch: () => {},
319 });
320
321 helpers.objIncrement(queryTest, ["count"]);
322
323 const result = client.getQueryData<TestData>(queryTest.queryKey);
324 assertEquals(result?.count, 11);
325
326 // Restore
327 restoreFns[0]();
328 const restored = client.getQueryData<TestData>(queryTest.queryKey);
329 assertEquals(restored?.count, 10);
330});
331
332test("objIncrement - should increment by custom amount", () => {
333 const { client, queryTest } = createTestQueryClient();
334
335 const helpers = queryClientOptimisticHelpers(client)({
336 onRestore: () => {},
337 onRefetch: () => {},
338 });
339
340 helpers.objIncrement(queryTest, ["count"], 5);
341
342 const result = client.getQueryData<TestData>(queryTest.queryKey);
343 assertEquals(result?.count, 15);
344});
345
346test("objIncrement - should skip if value is not a number", () => {
347 const { client, queryTest } = createTestQueryClient();
348 const restoreFns: Array<() => void> = [];
349
350 const helpers = queryClientOptimisticHelpers(client)({
351 onRestore: (fn) => restoreFns.push(fn),
352 onRefetch: () => {},
353 });
354
355 helpers.objIncrement(queryTest, ["name"] as any, 5);
356
357 const result = client.getQueryData<TestData>(queryTest.queryKey);
358 assertEquals(result?.name, "Test"); // Unchanged
359 assertEquals(restoreFns.length, 0); // No restore registered
360});
361
362// ============================================================================
363// objDecrement() tests
364// ============================================================================
365
366test("objDecrement - should decrement by 1 by default", () => {
367 const { client, queryTest } = createTestQueryClient();
368
369 const helpers = queryClientOptimisticHelpers(client)({
370 onRestore: () => {},
371 onRefetch: () => {},
372 });
373
374 helpers.objDecrement(queryTest, ["count"]);
375
376 const result = client.getQueryData<TestData>(queryTest.queryKey);
377 assertEquals(result?.count, 9);
378});
379
380test("objDecrement - should decrement by custom amount", () => {
381 const { client, queryTest } = createTestQueryClient();
382
383 const helpers = queryClientOptimisticHelpers(client)({
384 onRestore: () => {},
385 onRefetch: () => {},
386 });
387
388 helpers.objDecrement(queryTest, ["count"], 3);
389
390 const result = client.getQueryData<TestData>(queryTest.queryKey);
391 assertEquals(result?.count, 7);
392});
393
394// ============================================================================
395// objToggle() tests
396// ============================================================================
397
398test("objToggle - should toggle boolean value", () => {
399 const { client, queryTest } = createTestQueryClient();
400 const restoreFns: Array<() => void> = [];
401
402 const helpers = queryClientOptimisticHelpers(client)({
403 onRestore: (fn) => restoreFns.push(fn),
404 onRefetch: () => {},
405 });
406
407 helpers.objToggle(queryTest, ["active"]);
408
409 const result = client.getQueryData<TestData>(queryTest.queryKey);
410 assertEquals(result?.active, false);
411
412 // Restore
413 restoreFns[0]();
414 const restored = client.getQueryData<TestData>(queryTest.queryKey);
415 assertEquals(restored?.active, true);
416});
417
418test("objToggle - should toggle nested boolean", () => {
419 const { client, queryTest } = createTestQueryClient();
420
421 const helpers = queryClientOptimisticHelpers(client)({
422 onRestore: () => {},
423 onRefetch: () => {},
424 });
425
426 helpers.objToggle(queryTest, ["settings", "notifications"]);
427
428 const result = client.getQueryData<TestData>(queryTest.queryKey);
429 assertEquals(result?.settings.notifications, false);
430});
431
432test("objToggle - should skip if value is not boolean", () => {
433 const { client, queryTest } = createTestQueryClient();
434 const restoreFns: Array<() => void> = [];
435
436 const helpers = queryClientOptimisticHelpers(client)({
437 onRestore: (fn) => restoreFns.push(fn),
438 onRefetch: () => {},
439 });
440
441 helpers.objToggle(queryTest, ["count"] as any);
442
443 const result = client.getQueryData<TestData>(queryTest.queryKey);
444 assertEquals(result?.count, 10); // Unchanged
445 assertEquals(restoreFns.length, 0);
446});
447
448// ============================================================================
449// objSetMany() tests
450// ============================================================================
451
452test("objSetMany - should merge multiple properties", () => {
453 const { client, queryTest } = createTestQueryClient();
454 const restoreFns: Array<() => void> = [];
455
456 const helpers = queryClientOptimisticHelpers(client)({
457 onRestore: (fn) => restoreFns.push(fn),
458 onRefetch: () => {},
459 });
460
461 helpers.objSetMany(queryTest, ["settings"], {
462 theme: "light",
463 });
464
465 const result = client.getQueryData<TestData>(queryTest.queryKey);
466 assertEquals(result?.settings.theme, "light");
467 assertEquals(result?.settings.notifications, true); // Unchanged
468
469 // Restore
470 restoreFns[0]();
471 const restored = client.getQueryData<TestData>(queryTest.queryKey);
472 assertEquals(restored?.settings.theme, "dark");
473});
474
475test("objSetMany - should work at root level", () => {
476 const { client, queryTest } = createTestQueryClient();
477
478 const helpers = queryClientOptimisticHelpers(client)({
479 onRestore: () => {},
480 onRefetch: () => {},
481 });
482
483 helpers.objSetMany(queryTest, [], {
484 name: "Updated",
485 count: 99,
486 });
487
488 const result = client.getQueryData<TestData>(queryTest.queryKey);
489 assertEquals(result?.name, "Updated");
490 assertEquals(result?.count, 99);
491 assertEquals(result?.active, true); // Unchanged
492});
493
494// ============================================================================
495// arrayPush() tests
496// ============================================================================
497
498test("arrayPush - should add items to end of array", () => {
499 const { client, queryTest } = createTestQueryClient();
500 const restoreFns: Array<() => void> = [];
501
502 const helpers = queryClientOptimisticHelpers(client)({
503 onRestore: (fn) => restoreFns.push(fn),
504 onRefetch: () => {},
505 });
506
507 helpers.objArrayPush(
508 queryTest,
509 ["items"],
510 { id: 4, label: "fourth" },
511 { id: 5, label: "fifth" },
512 );
513
514 const result = client.getQueryData<TestData>(queryTest.queryKey);
515 assertEquals(result?.items.length, 5);
516 assertEquals(result?.items[3], { id: 4, label: "fourth" });
517 assertEquals(result?.items[4], { id: 5, label: "fifth" });
518
519 // Restore
520 restoreFns[0]();
521 const restored = client.getQueryData<TestData>(queryTest.queryKey);
522 assertEquals(restored?.items.length, 3);
523});
524
525test("arrayPush - should work with simple arrays", () => {
526 const { client, queryTest } = createTestQueryClient();
527
528 const helpers = queryClientOptimisticHelpers(client)({
529 onRestore: () => {},
530 onRefetch: () => {},
531 });
532
533 helpers.objArrayPush(queryTest, ["tags"], "delta", "epsilon");
534
535 const result = client.getQueryData<TestData>(queryTest.queryKey);
536 assertEquals(result?.tags, ["alpha", "beta", "gamma", "delta", "epsilon"]);
537});
538
539// ============================================================================
540// arrayUnshift() tests
541// ============================================================================
542
543test("arrayUnshift - should add items to beginning of array", () => {
544 const { client, queryTest } = createTestQueryClient();
545 const restoreFns: Array<() => void> = [];
546
547 const helpers = queryClientOptimisticHelpers(client)({
548 onRestore: (fn) => restoreFns.push(fn),
549 onRefetch: () => {},
550 });
551
552 helpers.objArrayUnshift(
553 queryTest,
554 ["items"],
555 { id: 0, label: "zeroth" },
556 );
557
558 const result = client.getQueryData<TestData>(queryTest.queryKey);
559 assertEquals(result?.items.length, 4);
560 assertEquals(result?.items[0], { id: 0, label: "zeroth" });
561 assertEquals(result?.items[1], { id: 1, label: "first" });
562
563 // Restore
564 restoreFns[0]();
565 const restored = client.getQueryData<TestData>(queryTest.queryKey);
566 assertEquals(restored?.items.length, 3);
567 assertEquals(restored?.items[0], { id: 1, label: "first" });
568});
569
570// ============================================================================
571// arrayRemoveItem() tests
572// ============================================================================
573
574test("arrayRemoveItem - should remove items matching predicate", () => {
575 const { client, queryTest } = createTestQueryClient();
576 const restoreFns: Array<() => void> = [];
577
578 const helpers = queryClientOptimisticHelpers(client)({
579 onRestore: (fn) => restoreFns.push(fn),
580 onRefetch: () => {},
581 });
582
583 helpers.objArrayRemove(
584 queryTest,
585 ["items"],
586 (item) => item.id === 2,
587 );
588
589 const result = client.getQueryData<TestData>(queryTest.queryKey);
590 assertEquals(result?.items.length, 2);
591 assertEquals(result?.items[0], { id: 1, label: "first" });
592 assertEquals(result?.items[1], { id: 3, label: "third" });
593
594 // Restore
595 restoreFns[0]();
596 const restored = client.getQueryData<TestData>(queryTest.queryKey);
597 assertEquals(restored?.items.length, 3);
598});
599
600test("arrayRemoveItem - should remove multiple items", () => {
601 const { client, queryTest } = createTestQueryClient();
602
603 const helpers = queryClientOptimisticHelpers(client)({
604 onRestore: () => {},
605 onRefetch: () => {},
606 });
607
608 helpers.objArrayRemove(
609 queryTest,
610 ["items"],
611 (item) => item.id > 1,
612 );
613
614 const result = client.getQueryData<TestData>(queryTest.queryKey);
615 assertEquals(result?.items.length, 1);
616 assertEquals(result?.items[0], { id: 1, label: "first" });
617});
618
619test("arrayRemoveItem - should work with simple arrays", () => {
620 const { client, queryTest } = createTestQueryClient();
621
622 const helpers = queryClientOptimisticHelpers(client)({
623 onRestore: () => {},
624 onRefetch: () => {},
625 });
626
627 helpers.objArrayRemove(
628 queryTest,
629 ["tags"],
630 (tag) => tag === "beta",
631 );
632
633 const result = client.getQueryData<TestData>(queryTest.queryKey);
634 assertEquals(result?.tags, ["alpha", "gamma"]);
635});
636
637// ============================================================================
638// arrayUpdateItem() tests
639// ============================================================================
640
641test("arrayUpdateItem - should update items matching predicate", () => {
642 const { client, queryTest } = createTestQueryClient();
643 const restoreFns: Array<() => void> = [];
644
645 const helpers = queryClientOptimisticHelpers(client)({
646 onRestore: (fn) => restoreFns.push(fn),
647 onRefetch: () => {},
648 });
649
650 helpers.objArrayUpdate(
651 queryTest,
652 ["items"],
653 {
654 filter: (item) => item.id === 2,
655 update: (item) => ({ ...item, label: "UPDATED" }),
656 },
657 );
658
659 const result = client.getQueryData<TestData>(queryTest.queryKey);
660 assertEquals(result?.items[1], { id: 2, label: "UPDATED" });
661 assertEquals(result?.items[0], { id: 1, label: "first" });
662
663 // Restore
664 restoreFns[0]();
665 const restored = client.getQueryData<TestData>(queryTest.queryKey);
666 assertEquals(restored?.items[1], { id: 2, label: "second" });
667});
668
669test("arrayUpdateItem - should update multiple items", () => {
670 const { client, queryTest } = createTestQueryClient();
671
672 const helpers = queryClientOptimisticHelpers(client)({
673 onRestore: () => {},
674 onRefetch: () => {},
675 });
676
677 helpers.objArrayUpdate(
678 queryTest,
679 ["items"],
680 {
681 filter: (item) => item.id > 1,
682 update: (item) => ({ ...item, label: item.label.toUpperCase() }),
683 },
684 );
685
686 const result = client.getQueryData<TestData>(queryTest.queryKey);
687 assertEquals(result?.items[0].label, "first"); // Unchanged
688 assertEquals(result?.items[1].label, "SECOND");
689 assertEquals(result?.items[2].label, "THIRD");
690});
691
692test("arrayUpdateItem - predicate receives index", () => {
693 const { client, queryTest } = createTestQueryClient();
694
695 const helpers = queryClientOptimisticHelpers(client)({
696 onRestore: () => {},
697 onRefetch: () => {},
698 });
699
700 helpers.objArrayUpdate(
701 queryTest,
702 ["items"],
703 {
704 filter: (_item, index) => index === 0,
705 update: (item) => ({ ...item, label: "FIRST" }),
706 },
707 );
708
709 const result = client.getQueryData<TestData>(queryTest.queryKey);
710 assertEquals(result?.items[0].label, "FIRST");
711});
712
713test("arrayUpdateItem - should update all items when filter is omitted", () => {
714 const { client, queryTest } = createTestQueryClient();
715
716 const helpers = queryClientOptimisticHelpers(client)({
717 onRestore: () => {},
718 onRefetch: () => {},
719 });
720
721 helpers.objArrayUpdate(
722 queryTest,
723 ["items"],
724 {
725 update: (item) => ({ ...item, label: item.label.toUpperCase() }),
726 },
727 );
728
729 const result = client.getQueryData<TestData>(queryTest.queryKey);
730 assertEquals(result?.items.map((item) => item.label), ["FIRST", "SECOND", "THIRD"]);
731});
732
733// ============================================================================
734// objArrayUpsert() tests
735// ============================================================================
736
737test("objArrayUpsert - should update matching items without inserting", () => {
738 const { client, queryTest } = createTestQueryClient();
739 const restoreFns: Array<() => void> = [];
740
741 const helpers = queryClientOptimisticHelpers(client)({
742 onRestore: (fn) => restoreFns.push(fn),
743 onRefetch: () => {},
744 });
745
746 const upsertResult = helpers.objArrayUpsert(
747 queryTest,
748 ["items"],
749 {
750 filter: (item) => item.id === 2,
751 update: (item) => ({ ...item, label: "UPDATED" }),
752 insert: () => ({ id: 4, label: "fourth" }),
753 },
754 );
755
756 const result = client.getQueryData<TestData>(queryTest.queryKey);
757 assertEquals(upsertResult, { inserted: false });
758 assertEquals(result?.items.length, 3);
759 assertEquals(result?.items[1], { id: 2, label: "UPDATED" });
760
761 restoreFns[0]();
762 const restored = client.getQueryData<TestData>(queryTest.queryKey);
763 assertEquals(restored?.items, initialData.items);
764});
765
766test("objArrayUpsert - should insert a new item when nothing matches", () => {
767 const { client, queryTest } = createTestQueryClient();
768 const restoreFns: Array<() => void> = [];
769
770 const helpers = queryClientOptimisticHelpers(client)({
771 onRestore: (fn) => restoreFns.push(fn),
772 onRefetch: () => {},
773 });
774
775 const upsertResult = helpers.objArrayUpsert(
776 queryTest,
777 ["items"],
778 {
779 filter: (item) => item.id === 4,
780 insert: () => ({ id: 4, label: "fourth" }),
781 },
782 );
783
784 const result = client.getQueryData<TestData>(queryTest.queryKey);
785 assertEquals(upsertResult, { inserted: true });
786 assertEquals(result?.items.length, 4);
787 assertEquals(result?.items[3], { id: 4, label: "fourth" });
788
789 restoreFns[0]();
790 const restored = client.getQueryData<TestData>(queryTest.queryKey);
791 assertEquals(restored?.items, initialData.items);
792});
793
794// ============================================================================
795// arrayUpsert() tests
796// ============================================================================
797
798test("arrayUpsert - should update matching items without inserting", () => {
799 const { client, queryArray } = createArrayQueryClient();
800 const restoreFns: Array<() => void> = [];
801
802 const helpers = queryClientOptimisticHelpers(client)({
803 onRestore: (fn) => restoreFns.push(fn),
804 onRefetch: () => {},
805 });
806
807 const upsertResult = helpers.arrayUpsert(
808 queryArray,
809 {
810 filter: (item) => item.id === 2,
811 update: (item) => ({ ...item, label: "UPDATED" }),
812 insert: () => ({ id: 4, label: "fourth" }),
813 },
814 );
815
816 const result = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
817 assertEquals(upsertResult, { inserted: false });
818 assertEquals(result?.length, 3);
819 assertEquals(result?.[1], { id: 2, label: "UPDATED" });
820
821 restoreFns[0]();
822 const restored = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
823 assertEquals(restored, initialArrayItems);
824});
825
826test("arrayUpsert - should insert a new item when nothing matches", () => {
827 const { client, queryArray } = createArrayQueryClient();
828 const restoreFns: Array<() => void> = [];
829
830 const helpers = queryClientOptimisticHelpers(client)({
831 onRestore: (fn) => restoreFns.push(fn),
832 onRefetch: () => {},
833 });
834
835 const upsertResult = helpers.arrayUpsert(
836 queryArray,
837 {
838 filter: (item) => item.id === 4,
839 insert: () => ({ id: 4, label: "fourth" }),
840 },
841 );
842
843 const result = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
844 assertEquals(upsertResult, { inserted: true });
845 assertEquals(result?.length, 4);
846 assertEquals(result?.[3], { id: 4, label: "fourth" });
847
848 restoreFns[0]();
849 const restored = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
850 assertEquals(restored, initialArrayItems);
851});
852
853test("arrayUpsert - should skip if query doesn't exist", () => {
854 const { client } = createTestQueryClient();
855 const restoreFns: Array<() => void> = [];
856
857 const helpers = queryClientOptimisticHelpers(client)({
858 onRestore: (fn) => restoreFns.push(fn),
859 onRefetch: () => {},
860 });
861
862 const queryNonexistent = queryOptions({
863 queryKey: ["nonexistent-array"],
864 queryFn: (): TestArrayItem[] => initialArrayItems,
865 });
866
867 const upsertResult = helpers.arrayUpsert(
868 queryNonexistent,
869 {
870 filter: (item) => item.id === 4,
871 insert: () => ({ id: 4, label: "fourth" }),
872 },
873 );
874
875 assertEquals(upsertResult, { inserted: false });
876 assertEquals(restoreFns.length, 0);
877});
878
879// ============================================================================
880// arrayInsertIndex() tests
881// ============================================================================
882
883test("arrayInsertIndex - should insert at specific index", () => {
884 const { client, queryTest } = createTestQueryClient();
885 const restoreFns: Array<() => void> = [];
886
887 const helpers = queryClientOptimisticHelpers(client)({
888 onRestore: (fn) => restoreFns.push(fn),
889 onRefetch: () => {},
890 });
891
892 helpers.objArrayInsertIndex(
893 queryTest,
894 ["items"],
895 1,
896 { id: 99, label: "inserted" },
897 );
898
899 const result = client.getQueryData<TestData>(queryTest.queryKey);
900 assertEquals(result?.items.length, 4);
901 assertEquals(result?.items[0], { id: 1, label: "first" });
902 assertEquals(result?.items[1], { id: 99, label: "inserted" });
903 assertEquals(result?.items[2], { id: 2, label: "second" });
904
905 // Restore
906 restoreFns[0]();
907 const restored = client.getQueryData<TestData>(queryTest.queryKey);
908 assertEquals(restored?.items.length, 3);
909});
910
911test("arrayInsertIndex - should insert at beginning", () => {
912 const { client, queryTest } = createTestQueryClient();
913
914 const helpers = queryClientOptimisticHelpers(client)({
915 onRestore: () => {},
916 onRefetch: () => {},
917 });
918
919 helpers.objArrayInsertIndex(
920 queryTest,
921 ["tags"],
922 0,
923 "prefix",
924 );
925
926 const result = client.getQueryData<TestData>(queryTest.queryKey);
927 assertEquals(result?.tags, ["prefix", "alpha", "beta", "gamma"]);
928});
929
930test("arrayInsertIndex - should insert at end", () => {
931 const { client, queryTest } = createTestQueryClient();
932
933 const helpers = queryClientOptimisticHelpers(client)({
934 onRestore: () => {},
935 onRefetch: () => {},
936 });
937
938 helpers.objArrayInsertIndex(
939 queryTest,
940 ["tags"],
941 3,
942 "suffix",
943 );
944
945 const result = client.getQueryData<TestData>(queryTest.queryKey);
946 assertEquals(result?.tags, ["alpha", "beta", "gamma", "suffix"]);
947});
948
949test("arrayInsertIndex - should insert multiple items", () => {
950 const { client, queryTest } = createTestQueryClient();
951
952 const helpers = queryClientOptimisticHelpers(client)({
953 onRestore: () => {},
954 onRefetch: () => {},
955 });
956
957 helpers.objArrayInsertIndex(
958 queryTest,
959 ["tags"],
960 1,
961 "one",
962 "two",
963 );
964
965 const result = client.getQueryData<TestData>(queryTest.queryKey);
966 assertEquals(result?.tags, ["alpha", "one", "two", "beta", "gamma"]);
967});
968
969// ============================================================================
970// removeQuery() tests
971// ============================================================================
972
973test("removeQuery - should remove query from cache", () => {
974 const { client, queryTest } = createTestQueryClient();
975 const restoreFns: Array<() => void> = [];
976
977 const helpers = queryClientOptimisticHelpers(client)({
978 onRestore: (fn) => restoreFns.push(fn),
979 onRefetch: () => {},
980 });
981
982 helpers.removeQuery(queryTest);
983
984 const result = client.getQueryData<TestData>(queryTest.queryKey);
985 assertEquals(result, undefined);
986
987 // Restore should bring it back
988 restoreFns[0]();
989 const restored = client.getQueryData<TestData>(queryTest.queryKey);
990 assertEquals(restored?.name, "Test");
991});
992
993test("removeQuery - should skip if query doesn't exist", () => {
994 const { client } = createTestQueryClient();
995 const restoreFns: Array<() => void> = [];
996
997 const helpers = queryClientOptimisticHelpers(client)({
998 onRestore: (fn) => restoreFns.push(fn),
999 onRefetch: () => {},
1000 });
1001
1002 const queryNonexistent = queryOptions({
1003 queryKey: ["nonexistent"],
1004 queryFn: (): TestData => initialData,
1005 });
1006
1007 helpers.removeQuery(queryNonexistent);
1008
1009 assertEquals(restoreFns.length, 0);
1010});
1011
1012// ============================================================================
1013// refetchOnSettled() tests
1014// ============================================================================
1015
1016test("refetchOnSettled - should schedule a refetch without modifying data", () => {
1017 const { client, queryTest } = createTestQueryClient();
1018 const refetchFns: Array<() => void> = [];
1019 const restoreFns: Array<() => void> = [];
1020
1021 const helpers = queryClientOptimisticHelpers(client)({
1022 onRestore: (fn) => restoreFns.push(fn),
1023 onRefetch: (fn) => refetchFns.push(fn),
1024 });
1025
1026 const originalData = client.getQueryData<TestData>(queryTest.queryKey);
1027
1028 helpers.refetchOnSettled(queryTest);
1029
1030 const result = client.getQueryData<TestData>(queryTest.queryKey);
1031 // Data should not be modified
1032 assertEquals(result, originalData);
1033
1034 // A refetch should have been scheduled
1035 assertEquals(refetchFns.length, 1);
1036
1037 // No restore callbacks should be registered
1038 assertEquals(restoreFns.length, 0);
1039});
1040
1041test("refetchOnSettled - should skip if query doesn't exist", () => {
1042 const { client } = createTestQueryClient();
1043 const refetchFns: Array<() => void> = [];
1044 const restoreFns: Array<() => void> = [];
1045
1046 const helpers = queryClientOptimisticHelpers(client)({
1047 onRestore: (fn) => restoreFns.push(fn),
1048 onRefetch: (fn) => refetchFns.push(fn),
1049 });
1050
1051 const queryNonexistent = queryOptions({
1052 queryKey: ["nonexistent"],
1053 queryFn: (): TestData => initialData,
1054 });
1055
1056 helpers.refetchOnSettled(queryNonexistent);
1057
1058 // No refetch should be scheduled for non-existent query
1059 assertEquals(refetchFns.length, 0);
1060 assertEquals(restoreFns.length, 0);
1061});
1062
1063test("refetchOnSettled - should deduplicate refetches for the same query", () => {
1064 const { client, queryTest } = createTestQueryClient();
1065 const refetchFns: Array<() => void> = [];
1066 const restoreFns: Array<() => void> = [];
1067
1068 const helpers = queryClientOptimisticHelpers(client)({
1069 onRestore: (fn) => restoreFns.push(fn),
1070 onRefetch: (fn) => refetchFns.push(fn),
1071 });
1072
1073 // Call refetchOnSettled multiple times on the same query
1074 helpers.refetchOnSettled(queryTest);
1075 helpers.refetchOnSettled(queryTest);
1076 helpers.refetchOnSettled(queryTest);
1077
1078 // Only one refetch should be scheduled (deduplicated by query hash)
1079 assertEquals(refetchFns.length, 1);
1080 assertEquals(restoreFns.length, 0);
1081});
1082
1083// ============================================================================
1084// Integration tests
1085// ============================================================================
1086
1087test("integration - multiple operations work together", () => {
1088 const { client, queryTest } = createTestQueryClient();
1089 const restoreFns: Array<() => void> = [];
1090
1091 const helpers = queryClientOptimisticHelpers(client)({
1092 onRestore: (fn) => restoreFns.push(fn),
1093 onRefetch: () => {},
1094 });
1095
1096 // Perform multiple operations
1097 helpers.objIncrement(queryTest, ["count"], 5);
1098 helpers.objArrayPush(queryTest, ["tags"], "delta");
1099 helpers.objToggle(queryTest, ["active"]);
1100 helpers.objArrayRemove(queryTest, ["items"], (item) => item.id === 2);
1101
1102 const result = client.getQueryData<TestData>(queryTest.queryKey);
1103 assertEquals(result?.count, 15);
1104 assertEquals(result?.tags, ["alpha", "beta", "gamma", "delta"]);
1105 assertEquals(result?.active, false);
1106 assertEquals(result?.items.length, 2);
1107
1108 // Restore in reverse order
1109 restoreFns.reverse().forEach((fn) => fn());
1110
1111 const restored = client.getQueryData<TestData>(queryTest.queryKey);
1112 assertEquals(restored?.count, 10);
1113 assertEquals(restored?.tags, ["alpha", "beta", "gamma"]);
1114 assertEquals(restored?.active, true);
1115 assertEquals(restored?.items.length, 3);
1116});