diff --git a/packages/query/.size-limit.json b/packages/query/.size-limit.json index 7e0116a0..6e53c45d 100644 --- a/packages/query/.size-limit.json +++ b/packages/query/.size-limit.json @@ -4,13 +4,13 @@ "gzip": true, "path": "dist/index.production.js", "import": "*", - "limit": "4.6 kB" + "limit": "4.75 kB" }, { "name": "All publics (Brotli)", "path": "dist/index.production.js", "import": "*", - "limit": "4.25 kB" + "limit": "4.35 kB" }, { "name": "Minimal set (Gzip)", diff --git a/packages/query/src/cache.spec.ts b/packages/query/src/cache.spec.ts index d49880c7..9f929689 100644 --- a/packages/query/src/cache.spec.ts +++ b/packages/query/src/cache.spec.ts @@ -15,6 +15,15 @@ import { dataCacheFacade, errorCacheFacade } from './cache.js' +import { + append, + drop, + mapFirstPage, + mapPages, + restore, + restorePages +} from './utils.js' +import type { InfinitePages } from './client.js' describe('query', () => { describe('cache', () => { @@ -402,6 +411,68 @@ describe('query', () => { expect($cache(CountKey('a'))).toBe(1) expect($cache(CountKey('b'))).toBe(2) }) + + it('should take a removal back with `restore`', () => { + const ListKey = queryKey<[], number[]>('list') + const $cache = dataCacheFacade(new CacheStorage()) + const key = ListKey() + const isTwo = (value: number) => value === 2 + + $cache(key, [1, 2, 3]) + + const revert = $cache( + key, + list => drop(list, isTwo), + (current, prev) => restore(current, prev, isTwo) + ) + + $cache(key, list => append(list, 4)) + revert() + + expect($cache(key)).toEqual([1, 2, 3, 4]) + }) + + it('should take a removal from pages back with `restorePages`', () => { + interface Page { + items: number[] + } + + const PagesKey = queryKey<[], InfinitePages>('pages') + const $cache = dataCacheFacade(new CacheStorage()) + const key = PagesKey() + const isTwo = (value: number) => value === 2 + + $cache(key, { + pages: [ + { + items: [1, 2] + } + ], + next: 2, + more: true + }) + + const revert = $cache( + key, + pages => mapPages(pages, page => ({ + items: drop(page.items, isTwo) + })), + (current, prev) => restorePages(current, prev, isTwo, (restoreItems, page) => ({ + items: restoreItems(page.items) + })) + ) + + $cache(key, pages => mapFirstPage(pages, page => ({ + items: append(page.items, 3) + }))) + revert() + + expect($cache(key)?.pages).toEqual([ + { + items: [1, 2, 3] + } + ]) + }) }) }) diff --git a/packages/query/src/utils.spec.ts b/packages/query/src/utils.spec.ts index cf89efe0..ff8b2182 100644 --- a/packages/query/src/utils.spec.ts +++ b/packages/query/src/utils.spec.ts @@ -9,13 +9,15 @@ import { append, prepend, drop, + restore, map, sort, mapPages, mapPageAt, mapFirstPage, mapLastPage, - sortPages + sortPages, + restorePages } from './utils.js' import type { InfinitePages } from './client.js' @@ -75,6 +77,64 @@ describe('query', () => { expect(input).toEqual([3, 1, 2]) expect(sort(null, (a: number, b: number) => a - b)).toBe(null) }) + + describe('restore', () => { + const isTwo = (value: number) => value === 2 + + it('should put a value back at its previous index', () => { + expect(restore([1, 3], [1, 2, 3], isTwo)).toEqual([1, 2, 3]) + }) + + it('should keep values written since', () => { + expect(restore([1, 3, 4], [1, 2, 3], isTwo)).toEqual([1, 2, 3, 4]) + }) + + it('should put back every removed value', () => { + const isEven = (value: number) => value % 2 === 0 + + expect(restore([1, 3, 5], [1, 2, 3, 4, 5, 6], isEven)).toEqual([1, 2, 3, 4, 5, 6]) + }) + + it('should put a value back into an empty array', () => { + expect(restore([], [2], isTwo)).toEqual([2]) + }) + + it('should put a value at the end when the array became shorter', () => { + expect(restore([1], [1, 3, 4, 2], isTwo)).toEqual([1, 2]) + }) + + it('should not mutate arrays', () => { + const array = [1, 3] + const prev = [1, 2, 3] + + restore(array, prev, isTwo) + + expect(array).toEqual([1, 3]) + expect(prev).toEqual([1, 2, 3]) + }) + + it('should keep the array when it holds the value again', () => { + const array = [2, 1, 3] + + expect(restore(array, [1, 2, 3], isTwo)).toBe(array) + }) + + it('should keep the array when the previous one did not hold the value', () => { + const array = [1, 3] + + expect(restore(array, [1, 3], isTwo)).toBe(array) + }) + + it('should keep the array when there is no previous one', () => { + const array = [1, 3] + + expect(restore(array, null, isTwo)).toBe(array) + }) + + it('should keep the empty value', () => { + expect(restore(null, [1, 2, 3], isTwo)).toBe(null) + }) + }) }) describe('page helpers', () => { @@ -201,6 +261,124 @@ describe('query', () => { }) )).toBe(null) }) + + describe('restorePages', () => { + const isFour = (value: number) => value === 4 + const removed = mapPages(pages, page => ({ + items: drop(page.items, isFour) + })) + const adapter = (restoreItems: (items: number[]) => number[], page: Page) => ({ + items: restoreItems(page.items) + }) + + it('should put an item back into the page it stood in', () => { + expect(restorePages(removed, pages, isFour, adapter)).toEqual(pages) + }) + + it('should keep items written since', () => { + const current = mapFirstPage(removed, page => ({ + items: prepend(page.items, 0) + })) + + expect(restorePages(current, pages, isFour, adapter).pages).toEqual([ + { + items: [0, 1, 2] + }, + { + items: [3, 4] + }, + { + items: [5, 6] + } + ]) + }) + + it('should keep the pagination of the current pages', () => { + const result = restorePages({ + ...removed, + next: 4, + more: false + }, pages, isFour, adapter) + + expect(result.next).toBe(4) + expect(result.more).toBe(false) + }) + + it('should put back items of several pages', () => { + const isEven = (value: number) => value % 2 === 0 + const current = mapPages(pages, page => ({ + items: drop(page.items, isEven) + })) + + expect(restorePages(current, pages, isEven, adapter)).toEqual(pages) + }) + + it('should adapt a page shape', () => { + interface PostsPage { + posts: number[] + cursor: number + } + + const page = (posts: number[]): PostsPage => ({ + posts, + cursor: 1 + }) + const pagesOf = (posts: number[]): InfinitePages => ({ + pages: [page(posts)], + next: 2, + more: true + }) + + expect(restorePages(pagesOf([3]), pagesOf([3, 4]), isFour, (restoreItems, page) => ({ + ...page, + posts: restoreItems(page.posts) + }))).toEqual(pagesOf([3, 4])) + }) + + it('should keep pages when an item is back on another page', () => { + const current = { + ...removed, + pages: [ + { + items: [4] + }, + ...removed.pages + ] + } + + expect(restorePages(current, pages, isFour, adapter)).toBe(current) + }) + + it('should keep a page loaded since', () => { + const page = { + items: [7, 8] + } + const result = restorePages({ + ...removed, + pages: [...removed.pages, page] + }, pages, isFour, adapter) + + expect(result.pages.at(-1)).toBe(page) + expect(result.pages[1].items).toEqual([3, 4]) + }) + + it('should not put an item back when its page is no longer loaded', () => { + const current = { + ...removed, + pages: removed.pages.slice(0, 1) + } + + expect(restorePages(current, pages, isFour, adapter)).toEqual(current) + }) + + it('should keep pages when there are no previous ones', () => { + expect(restorePages | null, number>(removed, null, isFour, adapter)).toBe(removed) + }) + + it('should keep empty pages value', () => { + expect(restorePages | null, number>(null, pages, isFour, adapter)).toBe(null) + }) + }) }) }) }) diff --git a/packages/query/src/utils.ts b/packages/query/src/utils.ts index e5bdaf51..f3e926ca 100644 --- a/packages/query/src/utils.ts +++ b/packages/query/src/utils.ts @@ -73,6 +73,29 @@ export function drop( return (array ? (array as []).filter((value, index) => !what(value, index)) : array) as A } +/** + * Put values removed with `drop` back at their previous indexes in the current array. + * It is the `undo` of a removal for `$data`: unlike a plain revert, it keeps what was written since. + * An array that holds a matching value again stays as it is, so nothing comes back twice. + * @param array - Current array or empty value. + * @param prev - Array before the removal or empty value. + * @param what - Predicate that returns true for removed values. + * @returns Array with the values back, or the input array when there is nothing to put back. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function restore( + array: A, + prev: A, + what: (value: ArrayItem) => boolean +) { + return (array && prev && !(array as []).some(what) + ? (prev as []).reduce( + (next: unknown[], value, index) => (what(value) ? next.toSpliced(index, 0, value) : next), + array + ) + : array) as A +} + /** * Map values in an array-like cache value. * @param array - Previous array or empty value. @@ -216,3 +239,45 @@ export function sortPages

| EmptyValue ) } as P } + +/** + * Put items removed with `drop` back into the pages they stood in, in the current pages. + * It is the `undo` of a removal for `$data`: unlike a plain revert, it keeps what was written since. + * Pages stay as they are when a matching item is back on any of them, as a refetch can do, + * and a page that is no longer loaded gets nothing back. + * The callback adapts a page shape to the item list, as in `sortPages`. + * It is called first to read items, then again to write items back. + * @param pages - Current infinite pages data or empty value. + * @param prev - Infinite pages data before the removal or empty value. + * @param what - Predicate that returns true for removed items. + * @param callback - Page adapter that receives an item-list mapper and returns an updated page. + * @returns Infinite pages with the items back, or the input value when there is nothing to put back. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function restorePages

| EmptyValue, I>( + pages: P, + prev: P, + what: (item: I) => boolean, + callback: ( + restore: (items: I[]) => I[], + page: PagesItem

+ ) => PagesItem

+) { + const read = (page: PagesItem

) => { + let items: I[] + + callback(i => items = i, page) + + return items! + } + + return !prev || pages?.pages.some(page => read(page as PagesItem

).some(what)) + ? pages + : mapPages(pages, (page, index) => { + const prevPage = prev.pages[index] as PagesItem

| undefined + + return prevPage + ? callback(items => restore(items, read(prevPage), what), page) + : page + }) +} diff --git a/skills/nano-kit-query/DOCS.md b/skills/nano-kit-query/DOCS.md index 88eb7a4b..9002f765 100644 --- a/skills/nano-kit-query/DOCS.md +++ b/skills/nano-kit-query/DOCS.md @@ -388,6 +388,17 @@ revert() Previous data is `null` when the entry had none at the time of the write, so whatever it holds now came later. The spread above keeps it, while `current && prev && { ... }` would clear it. +A removal from a list has a ready-made undo function: `restore` puts what `drop` took out back where it stood, among everything written since, and `restorePages` does the same for infinite pages: + +```ts +const isRemoved = (post: Post) => post.id === postId +const revert = $data( + PostsKey(), + posts => drop(posts, isRemoved), + (current, prev) => restore(current, prev, isRemoved) +) +``` + #### Revalidating Cache Use `revalidate()` to mark cache entries as stale, triggering active queries to refetch: @@ -785,7 +796,7 @@ For comfortable cache mutations, use helpers that fit naturally into `$data` red ### Array Helpers -Use `append`, `prepend`, `drop`, `map`, and `sort` for array cache values: +Use `append`, `prepend`, `drop`, `restore`, `map`, and `sort` for array cache values: ```ts import { @@ -795,6 +806,7 @@ import { map, prepend, queryKey, + restore, sort } from '@nano_kit/query' @@ -810,6 +822,14 @@ $data(PostsKey(), posts => prepend(posts, pinnedPost)) /* Remove matching items. */ $data(PostsKey(), posts => drop(posts, post => post.id === removedPostId)) +/* Remove optimistically: if the request fails, revert() puts the items back. */ +const isRemoved = (post: Post) => post.id === removedPostId +const revert = $data( + PostsKey(), + posts => drop(posts, isRemoved), + (current, prev) => restore(current, prev, isRemoved) +) + /* Transform items. */ $data(PostsKey(), posts => map(posts, post => ({ ...post, @@ -822,18 +842,22 @@ $data(PostsKey(), posts => sort(posts, (a, b) => b.createdAt - a.createdAt)) The helpers return the original empty value for operations where that makes sense, or create a new array when adding a value. +`restore` undoes a `drop` as the third argument of `$data`. It puts removed values back at their previous indexes in the array as it is by now, so what was written since stays. An array that holds a matching value again, as after a refetch, stays as it is. A previous index can be off by what was added since, so when the client knows the order of the list, such as by date, wrap the result in `sort`. + ### Infinite Page Helpers -Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, and `sortPages` for [`InfinitePages`](#infinite) data: +Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, `sortPages`, and `restorePages` for [`InfinitePages`](#infinite) data: ```ts import { type InfinitePages, client, + drop, mapFirstPage, mapPages, prepend, queryKey, + restorePages, sortPages } from '@nano_kit/query' @@ -868,10 +892,26 @@ $data(FeedKey(), data => sortPages( posts: sort(page.posts) }) )) + +/* Remove optimistically: if the request fails, revert() puts the items back. */ +const isRemoved = (post: Post) => post.id === removedPostId +const revert = $data( + FeedKey(), + data => mapPages(data, page => ({ + ...page, + posts: drop(page.posts, isRemoved) + })), + (current, prev) => restorePages(current, prev, isRemoved, (restore, page) => ({ + ...page, + posts: restore(page.posts) + })) +) ``` `sortPages` collects items from every page, sorts the combined list, and writes items back into the original page chunks. Pagination metadata such as `next` and `more` stays intact. +`restorePages` puts removed items back into the pages they stood in, and its callback adapts the page shape the same way. Pages stay as they are when a matching item is back on any page, as a refetch can do, and a page that is no longer loaded gets nothing back. As with `restore`, when the client knows the order of the list, wrap the result in `sortPages`. + ## Request Context Query and mutation fetcher functions receive request context as last parameter. It provides methods to manage request lifecycle: diff --git a/skills/nano-kit-query/SKILL.md b/skills/nano-kit-query/SKILL.md index 271f48a8..563aee7d 100644 --- a/skills/nano-kit-query/SKILL.md +++ b/skills/nano-kit-query/SKILL.md @@ -58,6 +58,7 @@ metadata: - After a mutation, `revalidate` the affected keys: cached data stays visible while the refetch runs. `invalidate` blanks the entries and causes loading flashes; use it for session changes and hard resets. - Optimistic update: write the cache first, register the revert with the error callback of the request context, then send the request. - A plain revert puts back the whole value and drops what was written since. When other writes can reach the entry while the request is in flight, such as a second press or a change to another field, give `$data` the function that undoes only this change. +- The function that undoes a `drop` is ready-made: `restore` for arrays, `restorePages` for infinite pages. They put items back at their previous index in the data as it is by now, and leave it alone when a refetch has already brought them back. - `keys(...)` sweeps every registered builder at once; on login, logout or locale change invalidate everything except keys that must survive, such as translations. ## Settings that matter diff --git a/website/src/content/docs/query/advanced.mdx b/website/src/content/docs/query/advanced.mdx index 746bfd11..351b3bb7 100644 --- a/website/src/content/docs/query/advanced.mdx +++ b/website/src/content/docs/query/advanced.mdx @@ -197,7 +197,7 @@ For comfortable cache mutations, use helpers that fit naturally into [`$data`](/ ### Array Helpers -Use `append`, `prepend`, `drop`, `map`, and `sort` for array cache values: +Use `append`, `prepend`, `drop`, `restore`, `map`, and `sort` for array cache values: ```ts import { @@ -207,6 +207,7 @@ import { map, prepend, queryKey, + restore, sort } from '@nano_kit/query' @@ -222,6 +223,14 @@ $data(PostsKey(), posts => prepend(posts, pinnedPost)) /* Remove matching items. */ $data(PostsKey(), posts => drop(posts, post => post.id === removedPostId)) +/* Remove optimistically: if the request fails, revert() puts the items back. */ +const isRemoved = (post: Post) => post.id === removedPostId +const revert = $data( + PostsKey(), + posts => drop(posts, isRemoved), + (current, prev) => restore(current, prev, isRemoved) +) + /* Transform items. */ $data(PostsKey(), posts => map(posts, post => ({ ...post, @@ -234,18 +243,22 @@ $data(PostsKey(), posts => sort(posts, (a, b) => b.createdAt - a.createdAt)) The helpers return the original empty value for operations where that makes sense, or create a new array when adding a value. +`restore` undoes a `drop` as the [third argument of `$data`](/query/core-concepts#reading-and-writing-data). It puts removed values back at their previous indexes in the array as it is by now, so what was written since stays. An array that holds a matching value again, as after a refetch, stays as it is. A previous index can be off by what was added since, so when the client knows the order of the list, such as by date, wrap the result in `sort`. + ### Infinite Page Helpers -Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, and `sortPages` for [`InfinitePages`](#infinite) data: +Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, `sortPages`, and `restorePages` for [`InfinitePages`](#infinite) data: ```ts import { type InfinitePages, client, + drop, mapFirstPage, mapPages, prepend, queryKey, + restorePages, sortPages } from '@nano_kit/query' @@ -280,10 +293,26 @@ $data(FeedKey(), data => sortPages( posts: sort(page.posts) }) )) + +/* Remove optimistically: if the request fails, revert() puts the items back. */ +const isRemoved = (post: Post) => post.id === removedPostId +const revert = $data( + FeedKey(), + data => mapPages(data, page => ({ + ...page, + posts: drop(page.posts, isRemoved) + })), + (current, prev) => restorePages(current, prev, isRemoved, (restore, page) => ({ + ...page, + posts: restore(page.posts) + })) +) ``` `sortPages` collects items from every page, sorts the combined list, and writes items back into the original page chunks. Pagination metadata such as `next` and `more` stays intact. +`restorePages` puts removed items back into the pages they stood in, and its callback adapts the page shape the same way. Pages stay as they are when a matching item is back on any page, as a refetch can do, and a page that is no longer loaded gets nothing back. As with `restore`, when the client knows the order of the list, wrap the result in `sortPages`. + ## Request Context Query and mutation fetcher functions receive request context as last parameter. It provides methods to manage request lifecycle: diff --git a/website/src/content/docs/query/core-concepts.mdx b/website/src/content/docs/query/core-concepts.mdx index 3f3963a6..0d03e0a3 100644 --- a/website/src/content/docs/query/core-concepts.mdx +++ b/website/src/content/docs/query/core-concepts.mdx @@ -327,6 +327,17 @@ revert() Previous data is `null` when the entry had none at the time of the write, so whatever it holds now came later. The spread above keeps it, while `current && prev && { ... }` would clear it. +A removal from a list has a ready-made undo function: [`restore`](/query/advanced#array-helpers) puts what `drop` took out back where it stood, among everything written since, and [`restorePages`](/query/advanced#infinite-page-helpers) does the same for infinite pages: + +```ts +const isRemoved = (post: Post) => post.id === postId +const revert = $data( + PostsKey(), + posts => drop(posts, isRemoved), + (current, prev) => restore(current, prev, isRemoved) +) +``` + #### Revalidating Cache Use `revalidate()` to mark cache entries as stale, triggering active queries to refetch: