| author | |
| committer | |
| log | 47304f56e98bf841b757d16e472fb64a01c99fd2 |
| tree | 3ad08b3c5212b52b46b0f33e396d5c1928fce6fb |
| parent | 4b09ff4dba5fad5b7e2b44a0bdc2789918f74561 |
| signature |
3 files changed, 300 insertions(+), 11 deletions(-)
readme.md+2| ... | ... | @@ -191,6 +191,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list |
| 191 | 191 | - `arrayRemove` - remove items by a `filter` function |
| 192 | 192 | - `arrayFilter` - preserve items by a `filter` function |
| 193 | 193 | - `arrayUpdate` - update items by a `filter` + `update` function |
| 194 | - `arrayUpsert` - update items by a `filter`, or insert when there is no match | |
| 194 | 195 | - `arrayInsertIndex` - insert an item at an index |
| 195 | 196 | - Queries that are complex objects. Each function takes a type-safe object path to |
| 196 | 197 | evaluate. |
| ... | ... | @@ -204,6 +205,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list |
| 204 | 205 | - `objArrayRemove` - remove items from array by `filter` |
| 205 | 206 | - `objArrayFilter` - preserve items from array by `filter` |
| 206 | 207 | - `objArrayUpdate` - update items in array by `filter` + `update` |
| 208 | - `objArrayUpsert` - update items in array by `filter`, or insert when there is no match | |
| 207 | 209 | - `objArrayInsertIndex` - insert an item in an array at an index |
| 208 | 210 | |
| 209 | 211 | ## Debouncing |
src/tanstack-query.ts+106-11| ... | ... | @@ -7,6 +7,10 @@ export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = { |
| 7 | 7 | queryFn?: QueryFunction<T, any, never> | undefined; |
| 8 | 8 | }; |
| 9 | 9 | |
| 10 | type RequireAtLeastOneKey<T> = { | |
| 11 | [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>; | |
| 12 | }[keyof T]; | |
| 13 | ||
| 10 | 14 | export function boundQueryClientGet( |
| 11 | 15 | queryClient: QueryClient, |
| 12 | 16 | ): <T>({ queryKey }: QueryKeyAndFn<T>) => T | undefined { |
| ... | ... | @@ -50,7 +54,7 @@ class TanstackQueryOptimisticHelpers { |
| 50 | 54 | set = <Data>( |
| 51 | 55 | queryKey: QueryKeyAndFn<Data>, |
| 52 | 56 | value: Data | ((prev: Data | undefined) => Data | undefined), |
| 53 | ) => { | |
| 57 | ): void => { | |
| 54 | 58 | const prev = this.#get(queryKey); |
| 55 | 59 | |
| 56 | 60 | const newValue = typeof value === "function" |
| ... | ... | @@ -378,8 +382,11 @@ class TanstackQueryOptimisticHelpers { |
| 378 | 382 | >( |
| 379 | 383 | queryKey: QueryKeyAndFn<Data>, |
| 380 | 384 | path: Path, |
| 381 | { filter, update }: { | |
| 382 | filter: ( | |
| 385 | { | |
| 386 | filter, | |
| 387 | update, | |
| 388 | }: { | |
| 389 | filter?: ( | |
| 383 | 390 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, |
| 384 | 391 | index: number, |
| 385 | 392 | ) => boolean; |
| ... | ... | @@ -387,13 +394,62 @@ class TanstackQueryOptimisticHelpers { |
| 387 | 394 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, |
| 388 | 395 | ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never; |
| 389 | 396 | }, |
| 390 | ) => { | |
| 397 | ): { inserted: boolean } => { | |
| 398 | return this.objArrayUpsert(queryKey, path, { | |
| 399 | filter: filter ?? (() => true), | |
| 400 | update, | |
| 401 | }); | |
| 402 | }; | |
| 403 | ||
| 404 | /** | |
| 405 | * Update items in an array that match a `filter`, or insert a new one if there was no match. | |
| 406 | * If the query or path doesn't exist, the updater is skipped. | |
| 407 | */ | |
| 408 | objArrayUpsert = < | |
| 409 | Data extends object, | |
| 410 | const Path extends AllObjectPaths<Data>, | |
| 411 | >( | |
| 412 | queryKey: QueryKeyAndFn<Data>, | |
| 413 | path: Path, | |
| 414 | { | |
| 415 | filter, | |
| 416 | update, | |
| 417 | insert, | |
| 418 | }: | |
| 419 | & { | |
| 420 | filter: ( | |
| 421 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, | |
| 422 | index: number, | |
| 423 | ) => boolean; | |
| 424 | } | |
| 425 | & RequireAtLeastOneKey<{ | |
| 426 | /** Defaults to the identity function */ | |
| 427 | update: ( | |
| 428 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, | |
| 429 | ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never; | |
| 430 | /** Defaults to not inserting */ | |
| 431 | insert: () => GetObjectPath<Data, Path> extends Array<infer T> ? T : never; | |
| 432 | }>, | |
| 433 | ): { inserted: boolean } => { | |
| 391 | 434 | const prev = this.#get(queryKey); |
| 392 | if (!prev) return; | |
| 435 | if (!prev) return { inserted: false }; | |
| 393 | 436 | const { value: original, exists } = getPath(prev, path); |
| 394 | if (!exists || !Array.isArray(original)) return; | |
| 437 | if (!exists || !Array.isArray(original)) return { inserted: false }; | |
| 395 | 438 | |
| 396 | const newArray = original.map((item, index) => filter(item, index) ? update(item) : item); | |
| 439 | let matched = false; | |
| 440 | const newArray = original.map((item, index) => { | |
| 441 | if (filter(item, index)) { | |
| 442 | matched = true; | |
| 443 | return update ? update(item) : item; | |
| 444 | } else { | |
| 445 | return item; | |
| 446 | } | |
| 447 | }); | |
| 448 | let inserted = false; | |
| 449 | if (!matched && insert) { | |
| 450 | newArray.push(insert()); | |
| 451 | inserted = true; | |
| 452 | } | |
| 397 | 453 | this.#set( |
| 398 | 454 | queryKey, |
| 399 | 455 | (obj) => obj ? setPath(obj, path, newArray as any) : obj, |
| ... | ... | @@ -402,6 +458,7 @@ class TanstackQueryOptimisticHelpers { |
| 402 | 458 | // TODO: splice items back in case original changed |
| 403 | 459 | this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); |
| 404 | 460 | }); |
| 461 | return { inserted }; | |
| 405 | 462 | }; |
| 406 | 463 | |
| 407 | 464 | /** |
| ... | ... | @@ -439,7 +496,7 @@ class TanstackQueryOptimisticHelpers { |
| 439 | 496 | }; |
| 440 | 497 | |
| 441 | 498 | /** |
| 442 | * Push item(s) to the end of an array at an object path. | |
| 499 | * Push item(s) to the end of an array. | |
| 443 | 500 | * If the query or path doesn't exist, the updater is skipped. |
| 444 | 501 | */ |
| 445 | 502 | arrayPush = <Data>(queryKey: QueryKeyAndFn<Data[] | null>, ...items: Data[]) => { |
| ... | ... | @@ -531,16 +588,54 @@ class TanstackQueryOptimisticHelpers { |
| 531 | 588 | filter?: (item: Data, index: number) => boolean; |
| 532 | 589 | update: (item: Data) => Data; |
| 533 | 590 | }, |
| 534 | ) => { | |
| 591 | ): { inserted: boolean } => { | |
| 592 | return this.arrayUpsert(queryKey, { filter: filter ?? (() => true), update }); | |
| 593 | }; | |
| 594 | ||
| 595 | /** | |
| 596 | * Update items in an array that match a `filter`, or insert a new one if there was no match. | |
| 597 | * If the query doesn't exist, the updater is skipped. | |
| 598 | */ | |
| 599 | arrayUpsert = <Data>( | |
| 600 | queryKey: QueryKeyAndFn<Data[] | null>, | |
| 601 | { | |
| 602 | filter, | |
| 603 | update, | |
| 604 | insert, | |
| 605 | }: | |
| 606 | & { | |
| 607 | filter: (item: Data, index: number) => boolean; | |
| 608 | } | |
| 609 | & RequireAtLeastOneKey<{ | |
| 610 | /** Defaults to the identity function */ | |
| 611 | update: (item: Data) => Data; | |
| 612 | /** Defaults to not inserting */ | |
| 613 | insert: () => Data; | |
| 614 | }>, | |
| 615 | ): { inserted: boolean } => { | |
| 535 | 616 | const prev = this.#get(queryKey); |
| 536 | if (!prev || !Array.isArray(prev)) return; | |
| 617 | if (!prev || !Array.isArray(prev)) return { inserted: false }; | |
| 537 | 618 | |
| 538 | const newArray = prev.map((item, index) => (filter ? filter(item, index) : true) ? update(item) : item); | |
| 619 | let matched = false; | |
| 620 | const newArray = prev.map((item, index) => { | |
| 621 | if (filter(item, index)) { | |
| 622 | matched = true; | |
| 623 | return update ? update(item) : item; | |
| 624 | } else { | |
| 625 | return item; | |
| 626 | } | |
| 627 | }); | |
| 628 | let inserted = false; | |
| 629 | if (!matched && insert) { | |
| 630 | newArray.push(insert()); | |
| 631 | inserted = true; | |
| 632 | } | |
| 539 | 633 | this.#set(queryKey, newArray); |
| 540 | 634 | this.#onRestore(() => { |
| 541 | 635 | // TODO: splice items back in case original changed |
| 542 | 636 | this.#set(queryKey, prev); |
| 543 | 637 | }); |
| 638 | return { inserted }; | |
| 544 | 639 | }; |
| 545 | 640 | |
| 546 | 641 | /** |
test/tanstack-query-helpers.test.ts+192| ... | ... | @@ -15,6 +15,11 @@ interface TestData { |
| 15 | 15 | tags: string[]; |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | interface TestArrayItem { | |
| 19 | id: number; | |
| 20 | label: string; | |
| 21 | } | |
| 22 | ||
| 18 | 23 | const initialData: TestData = { |
| 19 | 24 | name: "Test", |
| 20 | 25 | count: 10, |
| ... | ... | @@ -31,6 +36,12 @@ const initialData: TestData = { |
| 31 | 36 | tags: ["alpha", "beta", "gamma"], |
| 32 | 37 | }; |
| 33 | 38 | |
| 39 | const initialArrayItems: TestArrayItem[] = [ | |
| 40 | { id: 1, label: "first" }, | |
| 41 | { id: 2, label: "second" }, | |
| 42 | { id: 3, label: "third" }, | |
| 43 | ]; | |
| 44 | ||
| 34 | 45 | function createTestQueryClient() { |
| 35 | 46 | const client = new QueryClient({ |
| 36 | 47 | defaultOptions: { queries: { retry: false } }, |
| ... | ... | @@ -46,6 +57,21 @@ function createTestQueryClient() { |
| 46 | 57 | return { client, queryTest }; |
| 47 | 58 | } |
| 48 | 59 | |
| 60 | function 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 | ||
| 49 | 75 | test("helpers can be spread and retain bound this", () => { |
| 50 | 76 | const { client, queryTest } = createTestQueryClient(); |
| 51 | 77 | |
| ... | ... | @@ -663,6 +689,172 @@ test("arrayUpdateItem - predicate receives index", () => { |
| 663 | 689 | assertEquals(result?.items[0].label, "FIRST"); |
| 664 | 690 | }); |
| 665 | 691 | |
| 692 | test("arrayUpdateItem - should update all items when filter is omitted", () => { | |
| 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 | update: (item) => ({ ...item, label: item.label.toUpperCase() }), | |
| 705 | }, | |
| 706 | ); | |
| 707 | ||
| 708 | const result = client.getQueryData<TestData>(queryTest.queryKey); | |
| 709 | assertEquals(result?.items.map((item) => item.label), ["FIRST", "SECOND", "THIRD"]); | |
| 710 | }); | |
| 711 | ||
| 712 | // ============================================================================ | |
| 713 | // objArrayUpsert() tests | |
| 714 | // ============================================================================ | |
| 715 | ||
| 716 | test("objArrayUpsert - should update matching items without inserting", () => { | |
| 717 | const { client, queryTest } = createTestQueryClient(); | |
| 718 | const restoreFns: Array<() => void> = []; | |
| 719 | ||
| 720 | const helpers = queryClientOptimisticHelpers(client)({ | |
| 721 | onRestore: (fn) => restoreFns.push(fn), | |
| 722 | onRefetch: () => {}, | |
| 723 | }); | |
| 724 | ||
| 725 | const upsertResult = helpers.objArrayUpsert( | |
| 726 | queryTest, | |
| 727 | ["items"], | |
| 728 | { | |
| 729 | filter: (item) => item.id === 2, | |
| 730 | update: (item) => ({ ...item, label: "UPDATED" }), | |
| 731 | insert: () => ({ id: 4, label: "fourth" }), | |
| 732 | }, | |
| 733 | ); | |
| 734 | ||
| 735 | const result = client.getQueryData<TestData>(queryTest.queryKey); | |
| 736 | assertEquals(upsertResult, { inserted: false }); | |
| 737 | assertEquals(result?.items.length, 3); | |
| 738 | assertEquals(result?.items[1], { id: 2, label: "UPDATED" }); | |
| 739 | ||
| 740 | restoreFns[0](); | |
| 741 | const restored = client.getQueryData<TestData>(queryTest.queryKey); | |
| 742 | assertEquals(restored?.items, initialData.items); | |
| 743 | }); | |
| 744 | ||
| 745 | test("objArrayUpsert - should insert a new item when nothing matches", () => { | |
| 746 | const { client, queryTest } = createTestQueryClient(); | |
| 747 | const restoreFns: Array<() => void> = []; | |
| 748 | ||
| 749 | const helpers = queryClientOptimisticHelpers(client)({ | |
| 750 | onRestore: (fn) => restoreFns.push(fn), | |
| 751 | onRefetch: () => {}, | |
| 752 | }); | |
| 753 | ||
| 754 | const upsertResult = helpers.objArrayUpsert( | |
| 755 | queryTest, | |
| 756 | ["items"], | |
| 757 | { | |
| 758 | filter: (item) => item.id === 4, | |
| 759 | insert: () => ({ id: 4, label: "fourth" }), | |
| 760 | }, | |
| 761 | ); | |
| 762 | ||
| 763 | const result = client.getQueryData<TestData>(queryTest.queryKey); | |
| 764 | assertEquals(upsertResult, { inserted: true }); | |
| 765 | assertEquals(result?.items.length, 4); | |
| 766 | assertEquals(result?.items[3], { id: 4, label: "fourth" }); | |
| 767 | ||
| 768 | restoreFns[0](); | |
| 769 | const restored = client.getQueryData<TestData>(queryTest.queryKey); | |
| 770 | assertEquals(restored?.items, initialData.items); | |
| 771 | }); | |
| 772 | ||
| 773 | // ============================================================================ | |
| 774 | // arrayUpsert() tests | |
| 775 | // ============================================================================ | |
| 776 | ||
| 777 | test("arrayUpsert - should update matching items without inserting", () => { | |
| 778 | const { client, queryArray } = createArrayQueryClient(); | |
| 779 | const restoreFns: Array<() => void> = []; | |
| 780 | ||
| 781 | const helpers = queryClientOptimisticHelpers(client)({ | |
| 782 | onRestore: (fn) => restoreFns.push(fn), | |
| 783 | onRefetch: () => {}, | |
| 784 | }); | |
| 785 | ||
| 786 | const upsertResult = helpers.arrayUpsert( | |
| 787 | queryArray, | |
| 788 | { | |
| 789 | filter: (item) => item.id === 2, | |
| 790 | update: (item) => ({ ...item, label: "UPDATED" }), | |
| 791 | insert: () => ({ id: 4, label: "fourth" }), | |
| 792 | }, | |
| 793 | ); | |
| 794 | ||
| 795 | const result = client.getQueryData<TestArrayItem[]>(queryArray.queryKey); | |
| 796 | assertEquals(upsertResult, { inserted: false }); | |
| 797 | assertEquals(result?.length, 3); | |
| 798 | assertEquals(result?.[1], { id: 2, label: "UPDATED" }); | |
| 799 | ||
| 800 | restoreFns[0](); | |
| 801 | const restored = client.getQueryData<TestArrayItem[]>(queryArray.queryKey); | |
| 802 | assertEquals(restored, initialArrayItems); | |
| 803 | }); | |
| 804 | ||
| 805 | test("arrayUpsert - should insert a new item when nothing matches", () => { | |
| 806 | const { client, queryArray } = createArrayQueryClient(); | |
| 807 | const restoreFns: Array<() => void> = []; | |
| 808 | ||
| 809 | const helpers = queryClientOptimisticHelpers(client)({ | |
| 810 | onRestore: (fn) => restoreFns.push(fn), | |
| 811 | onRefetch: () => {}, | |
| 812 | }); | |
| 813 | ||
| 814 | const upsertResult = helpers.arrayUpsert( | |
| 815 | queryArray, | |
| 816 | { | |
| 817 | filter: (item) => item.id === 4, | |
| 818 | insert: () => ({ id: 4, label: "fourth" }), | |
| 819 | }, | |
| 820 | ); | |
| 821 | ||
| 822 | const result = client.getQueryData<TestArrayItem[]>(queryArray.queryKey); | |
| 823 | assertEquals(upsertResult, { inserted: true }); | |
| 824 | assertEquals(result?.length, 4); | |
| 825 | assertEquals(result?.[3], { id: 4, label: "fourth" }); | |
| 826 | ||
| 827 | restoreFns[0](); | |
| 828 | const restored = client.getQueryData<TestArrayItem[]>(queryArray.queryKey); | |
| 829 | assertEquals(restored, initialArrayItems); | |
| 830 | }); | |
| 831 | ||
| 832 | test("arrayUpsert - should skip if query doesn't exist", () => { | |
| 833 | const { client } = createTestQueryClient(); | |
| 834 | const restoreFns: Array<() => void> = []; | |
| 835 | ||
| 836 | const helpers = queryClientOptimisticHelpers(client)({ | |
| 837 | onRestore: (fn) => restoreFns.push(fn), | |
| 838 | onRefetch: () => {}, | |
| 839 | }); | |
| 840 | ||
| 841 | const queryNonexistent = queryOptions({ | |
| 842 | queryKey: ["nonexistent-array"], | |
| 843 | queryFn: (): TestArrayItem[] => initialArrayItems, | |
| 844 | }); | |
| 845 | ||
| 846 | const upsertResult = helpers.arrayUpsert( | |
| 847 | queryNonexistent, | |
| 848 | { | |
| 849 | filter: (item) => item.id === 4, | |
| 850 | insert: () => ({ id: 4, label: "fourth" }), | |
| 851 | }, | |
| 852 | ); | |
| 853 | ||
| 854 | assertEquals(upsertResult, { inserted: false }); | |
| 855 | assertEquals(restoreFns.length, 0); | |
| 856 | }); | |
| 857 | ||
| 666 | 858 | // ============================================================================ |
| 667 | 859 | // arrayInsertIndex() tests |
| 668 | 860 | // ============================================================================ |