cursedbelt 5.89.0 → 5.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/react/LazyToaster.d.ts +3 -0
  2. package/dist/react/LazyToaster.d.ts.map +1 -0
  3. package/dist/react/LazyToaster.js +21 -0
  4. package/dist/react/LazyToaster.js.map +1 -0
  5. package/dist/react/createApiHooks.d.ts +30 -0
  6. package/dist/react/createApiHooks.d.ts.map +1 -1
  7. package/dist/react/createApiHooks.js +31 -2
  8. package/dist/react/createApiHooks.js.map +1 -1
  9. package/dist/react/index.d.ts +2 -0
  10. package/dist/react/index.d.ts.map +1 -1
  11. package/dist/react/index.js +2 -0
  12. package/dist/react/index.js.map +1 -1
  13. package/dist/react/optimistic/index.d.ts +10 -0
  14. package/dist/react/optimistic/index.d.ts.map +1 -0
  15. package/dist/react/optimistic/index.js +22 -0
  16. package/dist/react/optimistic/index.js.map +1 -0
  17. package/dist/react/optimistic/optimistic.d.ts +159 -0
  18. package/dist/react/optimistic/optimistic.d.ts.map +1 -0
  19. package/dist/react/optimistic/optimistic.js +216 -0
  20. package/dist/react/optimistic/optimistic.js.map +1 -0
  21. package/dist/react/optimistic/revert.d.ts +47 -0
  22. package/dist/react/optimistic/revert.d.ts.map +1 -0
  23. package/dist/react/optimistic/revert.js +235 -0
  24. package/dist/react/optimistic/revert.js.map +1 -0
  25. package/package.json +1 -1
  26. package/scripts/styleAreas.ts +3 -0
  27. package/src/react/LazyToaster.spec.tsx +18 -0
  28. package/src/react/LazyToaster.tsx +26 -0
  29. package/src/react/createApiHooks.spec.tsx +77 -0
  30. package/src/react/createApiHooks.ts +58 -1
  31. package/src/react/index.ts +2 -0
  32. package/src/react/optimistic/index.ts +36 -0
  33. package/src/react/optimistic/optimistic.spec.tsx +225 -0
  34. package/src/react/optimistic/optimistic.ts +326 -0
  35. package/src/react/optimistic/queryTarget.spec.ts +64 -0
  36. package/src/react/optimistic/revert.spec.ts +114 -0
  37. package/src/react/optimistic/revert.ts +238 -0
  38. package/src/react/optimistic/staysOffTheEntry.spec.ts +38 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * `optimistic()` — do what the user asked NOW, tell the server after, and take it back only if
3
+ * the server says no.
4
+ *
5
+ * The owner, 2026-10-22 (forge task 3583): *"I want the cursedforge apps to use optimistic
6
+ * updates, where the UI doesn't wait for the server before doing what the user wants … if the
7
+ * delete fails it can popup a toast error but it would already be on the next track either way."*
8
+ *
9
+ * optimistic({
10
+ * label: `delete “${track.title}”`,
11
+ * targets: [
12
+ * { set: setTracks, apply: (rows) => rows.filter((r) => r.id !== track.id) },
13
+ * { set: setTotal, apply: (n) => n - 1, revert: counterRevert },
14
+ * ],
15
+ * request: () => api.deleteTracks({ ids: [track.id] }),
16
+ * });
17
+ *
18
+ * What it does, in order:
19
+ *
20
+ * 1. **Applies every target's forward edit at once**, through a setState-shaped `set` — React's
21
+ * own setter, a store's, or {@link queryTarget} for a TanStack cache. It records the state
22
+ * each edit started from and produced, inside the updater, so the rollback is derived
23
+ * (`revert.ts`) and no caller writes an undo by hand.
24
+ * 2. **Sends the request** — after any earlier request with the same `key` has settled, so two
25
+ * quick edits to one record reach the server in the order they were made. The UI never waits
26
+ * for that queue; only the network does.
27
+ * 3. **On failure**, reverts exactly what this change touched (concurrent changes survive) and
28
+ * shows an error toast naming what did not happen, with **Retry** (OVL-9) — Retry applies the
29
+ * change again and resends it.
30
+ * 4. **While anything is in flight**, the page asks before it is closed or reloaded, because the
31
+ * user has already seen the change happen and would otherwise lose it without knowing.
32
+ *
33
+ * Side effects that must NOT be undone stay outside it — the user's own words: a deleted song
34
+ * that comes back after a failed delete comes back to the list, but the player stays on the next
35
+ * track. Call the player before `optimistic()`, not as a target.
36
+ *
37
+ * It never rejects: the resolved {@link OptimisticOutcome} says what happened, so a fire-and-forget
38
+ * call (`void optimistic(...)`) cannot leak an unhandled rejection.
39
+ */
40
+ import { defaultIdOf, type IdOf, inverseOf } from './revert.js';
41
+
42
+ /** A setState-shaped writer: takes an updater from the current state to the next. */
43
+ export type Setter<S> = (updater: (current: S) => S) => void;
44
+
45
+ export interface OptimisticTarget<S> {
46
+ /** Where the state lives — `setRows` from `useState`, a store's setter, `queryTarget(...)`. */
47
+ set: Setter<S>;
48
+ /** The forward change. Pure: React may call it twice in StrictMode. */
49
+ apply: (current: S) => S;
50
+ /** Replace the derived rollback — e.g. {@link counterRevert} for a count. */
51
+ revert?: (current: S, before: S, after: S) => S;
52
+ /** How array items are told apart, when they carry neither `id` nor `key`. */
53
+ idOf?: IdOf;
54
+ /** Runs once, when the change is made — {@link queryTarget} counts its changes in flight here. */
55
+ begin?: () => void;
56
+ /** Runs once the request settles either way — {@link queryTarget} refetches the truth here. */
57
+ settled?: () => void;
58
+ /**
59
+ * Which view this state belongs to, when one piece of state is reused for many — a track list
60
+ * that holds whichever folder is open. Read when the change is made and again when it would be
61
+ * reverted: if it differs, the state is a different list by now, so the rollback leaves it alone
62
+ * (that view's next load is the server's truth). Without it, a failed delete made in one folder
63
+ * would put the row back into whatever folder is open when the refusal arrives.
64
+ */
65
+ scope?: () => unknown;
66
+ }
67
+
68
+ /**
69
+ * A change to state that is not setState-shaped — an imperative store, a player's queue, a
70
+ * callback that patches rows. `now` makes the change; `undo` takes it back if the server refuses.
71
+ * Prefer a {@link OptimisticTarget} when there is a setter: its rollback is derived, and survives
72
+ * changes made since. An `undo` here is the caller's, so it should undo only what `now` did.
73
+ */
74
+ export interface OptimisticStep {
75
+ now: () => void;
76
+ undo: () => void;
77
+ }
78
+
79
+ export interface OptimisticOptions<R> {
80
+ /**
81
+ * What the user did, as a verb phrase the failure toast completes: `delete “Song”` →
82
+ * "Couldn't delete “Song”". Also names the change in {@link pendingChanges}.
83
+ */
84
+ label: string;
85
+ /**
86
+ * The state edits to make at once. Typed loosely on purpose: one change usually spans several
87
+ * pieces of state of different types (rows, a count, a selection). Each target's own `apply` is
88
+ * still checked against its `set` where it is written — {@link target} keeps them paired.
89
+ */
90
+ targets?: OptimisticTarget<any> | OptimisticStep | ReadonlyArray<OptimisticTarget<any> | OptimisticStep>;
91
+ /** The server call. A rejection is a failure; anything it resolves to is success. */
92
+ request: () => Promise<R>;
93
+ /**
94
+ * Requests with the same key go one at a time, in order — use the record's id for edits to one
95
+ * record. No key: the request goes at once.
96
+ */
97
+ key?: string;
98
+ /** After the server said yes — reconcile a temporary id, refresh a summary. Not reverted. */
99
+ onSuccess?: (result: R) => void;
100
+ /** After the rollback, beside the toast. */
101
+ onError?: (error: unknown) => void;
102
+ /** `false` silences the failure toast (the caller shows the failure itself). Default on. */
103
+ toast?: boolean;
104
+ /** The toast's second line. Default: the error's own message. */
105
+ describeError?: (error: unknown) => string | undefined;
106
+ }
107
+
108
+ export type OptimisticOutcome<R> = { ok: true; value: R } | { ok: false; error: unknown };
109
+
110
+ /** Pair a setter with its edit so the two are type-checked against each other. */
111
+ export const target = <S>(t: OptimisticTarget<S>): OptimisticTarget<S> => t;
112
+
113
+ // ── what is in flight ─────────────────────────────────────────────────────────────────────
114
+
115
+ export interface PendingChange {
116
+ id: number;
117
+ label: string;
118
+ key?: string;
119
+ startedAt: number;
120
+ }
121
+
122
+ let pending: readonly PendingChange[] = [];
123
+ let nextId = 1;
124
+ const listeners = new Set<() => void>();
125
+ const queues = new Map<string, Promise<unknown>>();
126
+
127
+ const emit = () => {
128
+ for (const listener of listeners) listener();
129
+ };
130
+
131
+ /** Every optimistic change whose request has not settled, oldest first. */
132
+ export const pendingChanges = (): readonly PendingChange[] => pending;
133
+
134
+ /** Subscribe to {@link pendingChanges} — the `useSyncExternalStore` shape. */
135
+ export const subscribePendingChanges = (listener: () => void): (() => void) => {
136
+ listeners.add(listener);
137
+ return () => {
138
+ listeners.delete(listener);
139
+ };
140
+ };
141
+
142
+ /**
143
+ * Resolves once nothing is pending — the moment to re-read a list that is not a TanStack query,
144
+ * so a read that started before the last change landed cannot draw it as undone.
145
+ */
146
+ export const whenSettled = (): Promise<void> =>
147
+ pending.length === 0
148
+ ? Promise.resolve()
149
+ : new Promise((resolve) => {
150
+ const off = subscribePendingChanges(() => {
151
+ if (pending.length > 0) return;
152
+ off();
153
+ resolve();
154
+ });
155
+ });
156
+
157
+ /**
158
+ * Closing the tab while a change is unsent loses it, and the user has already seen it happen.
159
+ * The listener exists only while something is pending, so an idle page leaves bfcache alone.
160
+ */
161
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
162
+ event.preventDefault();
163
+ // Chrome still wants returnValue set; the text itself is never shown.
164
+ event.returnValue = '';
165
+ };
166
+
167
+ const track = (change: PendingChange): (() => void) => {
168
+ const wasIdle = pending.length === 0;
169
+ pending = [...pending, change];
170
+ if (wasIdle && typeof window !== 'undefined') window.addEventListener('beforeunload', onBeforeUnload);
171
+ emit();
172
+ return () => {
173
+ pending = pending.filter((p) => p.id !== change.id);
174
+ if (pending.length === 0 && typeof window !== 'undefined') {
175
+ window.removeEventListener('beforeunload', onBeforeUnload);
176
+ }
177
+ emit();
178
+ };
179
+ };
180
+
181
+ /** Run `task` after every earlier task under `key` has settled. */
182
+ const enqueue = <R>(key: string | undefined, task: () => Promise<R>): Promise<R> => {
183
+ if (key === undefined) return task();
184
+ const run = (queues.get(key) ?? Promise.resolve()).then(task, task);
185
+ const tail = run.catch(() => undefined);
186
+ queues.set(key, tail);
187
+ void tail.then(() => {
188
+ if (queues.get(key) === tail) queues.delete(key);
189
+ });
190
+ return run;
191
+ };
192
+
193
+ // ── the change ────────────────────────────────────────────────────────────────────────────
194
+
195
+ interface Applied {
196
+ rollback: () => void;
197
+ settled: () => void;
198
+ }
199
+
200
+ const isStep = (t: OptimisticTarget<unknown> | OptimisticStep): t is OptimisticStep =>
201
+ typeof (t as OptimisticStep).now === 'function';
202
+
203
+ function applyStep(step: OptimisticStep): Applied {
204
+ step.now();
205
+ return { rollback: step.undo, settled: () => undefined };
206
+ }
207
+
208
+ function applyTarget<S>(t: OptimisticTarget<S>): Applied {
209
+ // Captured INSIDE the updater: React runs updaters during render, not when `set` is called.
210
+ // The rollback updater is queued behind this one, so by the time it runs the capture is there.
211
+ let seen: { before: S; after: S } | undefined;
212
+ const scope = t.scope?.();
213
+ t.begin?.();
214
+ t.set((current) => {
215
+ const after = t.apply(current);
216
+ seen = { before: current, after };
217
+ return after;
218
+ });
219
+ return {
220
+ rollback: () => {
221
+ if (t.scope && !Object.is(t.scope(), scope)) return; // another view is on screen now
222
+ t.set((current) => {
223
+ if (!seen) return current; // the edit never ran (its owner unmounted) — nothing to undo
224
+ const { before, after } = seen;
225
+ return t.revert ? t.revert(current, before, after) : inverseOf(before, after, t.idOf ?? defaultIdOf)(current);
226
+ });
227
+ },
228
+ settled: () => t.settled?.(),
229
+ };
230
+ }
231
+
232
+ const messageOf = (error: unknown): string | undefined => {
233
+ if (error instanceof Error) return error.message || undefined;
234
+ if (typeof error === 'string') return error || undefined;
235
+ return undefined;
236
+ };
237
+
238
+ /** Apply now, send, revert on failure. See the file header. */
239
+ export function optimistic<R>(options: OptimisticOptions<R>): Promise<OptimisticOutcome<R>> {
240
+ const { label, request, key, onSuccess, onError } = options;
241
+ const targets = options.targets === undefined ? [] : Array.isArray(options.targets) ? options.targets : [options.targets];
242
+ const applied = (targets as (OptimisticTarget<unknown> | OptimisticStep)[]).map((t) =>
243
+ isStep(t) ? applyStep(t) : applyTarget(t),
244
+ );
245
+ const done = track({ id: nextId++, label, key, startedAt: Date.now() });
246
+
247
+ return enqueue(key, request).then(
248
+ (value): OptimisticOutcome<R> => {
249
+ done();
250
+ for (const a of applied) a.settled();
251
+ onSuccess?.(value);
252
+ return { ok: true, value };
253
+ },
254
+ async (error: unknown): Promise<OptimisticOutcome<R>> => {
255
+ done();
256
+ // Newest-first, so a target listed twice unwinds in the order it was wound.
257
+ for (const a of [...applied].reverse()) a.rollback();
258
+ for (const a of applied) a.settled();
259
+ onError?.(error);
260
+ if (options.toast !== false) {
261
+ // Loaded at the first refusal, never before: a static import would put sonner (~10 kB
262
+ // gzip) back on every app's entry chunk, which cursedbelt 5.64 took it off (forge task
263
+ // 2900) — flix's and roms' entries grew by it the first time they imported this.
264
+ const { toast } = await import('../Toast.js');
265
+ toast.error(`Couldn’t ${label}`, {
266
+ id: `optimistic:${key ?? label}`,
267
+ description: options.describeError ? options.describeError(error) : messageOf(error),
268
+ action: { label: 'Retry', onClick: () => void optimistic(options) },
269
+ });
270
+ }
271
+ return { ok: false, error };
272
+ },
273
+ );
274
+ }
275
+
276
+ // ── TanStack Query ────────────────────────────────────────────────────────────────────────
277
+
278
+ /**
279
+ * The query-client surface {@link queryTarget} needs — structural, so this module never imports
280
+ * `@tanstack/react-query` (an optional peer) and a second copy of it cannot break the types.
281
+ */
282
+ export interface OptimisticQueryClient {
283
+ cancelQueries: (filters: { queryKey: readonly unknown[]; exact?: boolean }) => unknown;
284
+ setQueryData: (queryKey: readonly unknown[], updater: (old: unknown) => unknown) => unknown;
285
+ invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown;
286
+ }
287
+
288
+ /** Changes in flight per cache entry, so the entry is refetched once, after the LAST of them. */
289
+ const inFlight = new Map<string, number>();
290
+
291
+ /**
292
+ * A TanStack cache entry as a target's `set`: cancels the key's in-flight fetch first (so a
293
+ * response already on the wire cannot land on top of the change), writes through `setQueryData`,
294
+ * and refetches the key once the request settles, so the cache ends on the server's truth.
295
+ * An entry with no data yet is left alone.
296
+ *
297
+ * 🔴 The refetch waits for the LAST change in flight on the entry. Refetching after each one
298
+ * (the first version) meant two quick deletes drew the second row back for a moment: the first
299
+ * answer's refetch read the server before the second delete had landed (collections and desk
300
+ * both met it, forge task 3583; TanStack's own docs gate on `isMutating`).
301
+ *
302
+ * targets: [{ ...queryTarget(queryClient, ['lists']), apply: (lists) => lists.filter(...) }]
303
+ */
304
+ export function queryTarget<S>(
305
+ client: OptimisticQueryClient,
306
+ queryKey: readonly unknown[],
307
+ { refetch = true }: { refetch?: boolean } = {},
308
+ ): Pick<OptimisticTarget<S>, 'set' | 'begin' | 'settled'> {
309
+ const entry = JSON.stringify(queryKey);
310
+ return {
311
+ set: (updater) => {
312
+ void client.cancelQueries({ queryKey, exact: true });
313
+ client.setQueryData(queryKey, (old) => (old === undefined ? old : updater(old as S)));
314
+ },
315
+ begin: () => inFlight.set(entry, (inFlight.get(entry) ?? 0) + 1),
316
+ settled: () => {
317
+ const left = (inFlight.get(entry) ?? 1) - 1;
318
+ if (left > 0) {
319
+ inFlight.set(entry, left);
320
+ return;
321
+ }
322
+ inFlight.delete(entry);
323
+ if (refetch) void client.invalidateQueries({ queryKey });
324
+ },
325
+ };
326
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `queryTarget` refetches a cache entry once, after the LAST optimistic change on it settles.
3
+ * Refetching after each one drew the second of two quick deletes back for a moment: the first
4
+ * answer's refetch read the server before the second delete had landed (collections and desk both
5
+ * met it, forge task 3583). Its own file so it imports only what predates the fix.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, type Mock, spyOn, test } from 'bun:test';
8
+ import { toast } from '../Toast.js';
9
+ import { optimistic, queryTarget } from './optimistic.js';
10
+
11
+ type Row = { id: string; title: string };
12
+ const rows: Row[] = [
13
+ { id: 'a', title: 'A' },
14
+ { id: 'b', title: 'B' },
15
+ { id: 'c', title: 'C' },
16
+ ];
17
+
18
+ const deferred = <T,>() => {
19
+ let resolve!: (value: T) => void;
20
+ const promise = new Promise<T>((res) => {
21
+ resolve = res;
22
+ });
23
+ return { promise, resolve };
24
+ };
25
+
26
+ let toastError: Mock<typeof toast.error>;
27
+ beforeEach(() => {
28
+ toastError = spyOn(toast, 'error').mockImplementation(() => 0);
29
+ });
30
+ afterEach(() => toastError.mockRestore());
31
+
32
+ describe('queryTarget', () => {
33
+ test('two changes on one cache entry refetch it ONCE, after the last settles', async () => {
34
+ const calls: string[] = [];
35
+ let data: unknown = rows;
36
+ const client = {
37
+ cancelQueries: () => undefined,
38
+ setQueryData: (_k: readonly unknown[], updater: (old: unknown) => unknown) => {
39
+ data = updater(data);
40
+ },
41
+ invalidateQueries: () => void calls.push('invalidate'),
42
+ };
43
+ const first = deferred<void>();
44
+ const second = deferred<void>();
45
+ const one = optimistic({
46
+ label: 'delete “A”',
47
+ targets: { ...queryTarget<Row[]>(client, ['rows']), apply: (r: Row[]) => r.filter((x) => x.id !== 'a') },
48
+ request: () => first.promise,
49
+ });
50
+ const two = optimistic({
51
+ label: 'delete “B”',
52
+ targets: { ...queryTarget<Row[]>(client, ['rows']), apply: (r: Row[]) => r.filter((x) => x.id !== 'b') },
53
+ request: () => second.promise,
54
+ });
55
+ first.resolve();
56
+ await one;
57
+ expect(calls).toEqual([]); // B is still in flight: a refetch now would draw it back
58
+ second.resolve();
59
+ await two;
60
+ expect(calls).toEqual(['invalidate']);
61
+ expect((data as Row[]).map((r) => r.id)).toEqual(['c']);
62
+ });
63
+
64
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { counterRevert, inverseOf } from './revert.js';
3
+
4
+ type Row = { id: string; title: string; liked?: boolean };
5
+ const rows: Row[] = [
6
+ { id: 'a', title: 'A' },
7
+ { id: 'b', title: 'B' },
8
+ { id: 'c', title: 'C' },
9
+ ];
10
+ const without = (list: Row[], id: string) => list.filter((r) => r.id !== id);
11
+ const rename = (list: Row[], id: string, title: string) => list.map((r) => (r.id === id ? { ...r, title } : r));
12
+
13
+ describe('inverseOf — arrays of records', () => {
14
+ test('a removed row comes back where it was', () => {
15
+ const after = without(rows, 'b');
16
+ expect(inverseOf(rows, after)(after)).toEqual(rows);
17
+ });
18
+
19
+ test('two deletes, the FIRST fails: only that row returns, the second stays gone', () => {
20
+ const afterA = without(rows, 'a');
21
+ const afterB = without(afterA, 'b');
22
+ expect(inverseOf(rows, afterA)(afterB).map((r) => r.id)).toEqual(['a', 'c']);
23
+ });
24
+
25
+ test('a removed row returns beside its surviving neighbor when rows before it went too', () => {
26
+ const afterB = without(rows, 'b');
27
+ const now = without(afterB, 'a'); // another change took A since
28
+ expect(inverseOf(rows, afterB)(now).map((r) => r.id)).toEqual(['b', 'c']);
29
+ });
30
+
31
+ test('a row the server or a refetch already put back is not doubled', () => {
32
+ const after = without(rows, 'b');
33
+ expect(inverseOf(rows, after)(rows)).toBe(rows);
34
+ });
35
+
36
+ test('an added row is taken out; rows added since stay', () => {
37
+ const after = [...rows, { id: 'tmp', title: 'New' }];
38
+ const now = [...after, { id: 'd', title: 'D' }];
39
+ expect(inverseOf(rows, after)(now).map((r) => r.id)).toEqual(['a', 'b', 'c', 'd']);
40
+ });
41
+
42
+ test('a patch reverts only the fields it set, and keeps a later edit to another field', () => {
43
+ const after = rows.map((r) => (r.id === 'b' ? { ...r, liked: true } : r));
44
+ const now = rename(after, 'b', 'Renamed since');
45
+ const reverted = inverseOf(rows, after)(now);
46
+ expect(reverted[1]).toEqual({ id: 'b', title: 'Renamed since' });
47
+ });
48
+
49
+ test('rename twice, the first fails: the second title stands', () => {
50
+ const first = rename(rows, 'b', 'X');
51
+ const second = rename(first, 'b', 'Y');
52
+ expect(inverseOf(rows, first)(second)[1]?.title).toBe('Y');
53
+ });
54
+
55
+ test('a reorder is undone only while the optimistic order still stands', () => {
56
+ const after = [rows[2], rows[0], rows[1]] as Row[];
57
+ expect(inverseOf(rows, after)(after).map((r) => r.id)).toEqual(['a', 'b', 'c']);
58
+ const movedAgain = [rows[0], rows[2], rows[1]] as Row[];
59
+ expect(inverseOf(rows, after)(movedAgain)).toBe(movedAgain);
60
+ });
61
+
62
+ test('nothing to revert returns the same reference, so a setter does not re-render', () => {
63
+ const after = without(rows, 'b');
64
+ const unrelated = [{ id: 'z', title: 'Z' }];
65
+ // `b` is not there, but neither is its neighbor; it still comes back, at the top.
66
+ expect(inverseOf(rows, after)(unrelated).map((r) => r.id)).toEqual(['b', 'z']);
67
+ expect(inverseOf(rows, rows)(unrelated)).toBe(unrelated);
68
+ });
69
+
70
+ test('a custom idOf names rows that carry no id', () => {
71
+ const files = [{ path: '/a' }, { path: '/b' }];
72
+ const after = files.slice(1);
73
+ const idOf = (f: unknown) => (f as { path: string }).path;
74
+ expect(inverseOf(files, after, idOf)(after)).toEqual(files);
75
+ });
76
+ });
77
+
78
+ describe('inverseOf — other shapes', () => {
79
+ test('primitive arrays behave as sets of values (a tag list)', () => {
80
+ const tags = ['x', 'y'];
81
+ const after = ['x', 'y', 'z'];
82
+ expect(inverseOf(tags, after)(['x', 'y', 'z', 'w'])).toEqual(['x', 'y', 'w']);
83
+ });
84
+
85
+ test('objects recurse key by key: a page { items, total }', () => {
86
+ const before = { items: rows, total: 3 };
87
+ const after = { items: without(rows, 'a'), total: 2 };
88
+ expect(inverseOf(before, after)(after)).toEqual(before);
89
+ });
90
+
91
+ test('a key the change added goes; one it removed comes back', () => {
92
+ const before: Record<string, number> = { a: 1, b: 2 };
93
+ const after: Record<string, number> = { a: 1, c: 3 };
94
+ expect(inverseOf(before, after)(after)).toEqual({ a: 1, b: 2 });
95
+ });
96
+
97
+ test('Set and Map members', () => {
98
+ const before = new Set(['a']);
99
+ const after = new Set(['a', 'b']);
100
+ expect([...inverseOf(before, after)(new Set(['a', 'b', 'c']))]).toEqual(['a', 'c']);
101
+ const mBefore = new Map([['a', { n: 1 }]]);
102
+ const mAfter = new Map<string, { n: number }>();
103
+ expect(inverseOf(mBefore, mAfter)(new Map()).get('a')).toEqual({ n: 1 });
104
+ });
105
+
106
+ test('a scalar returns only if it still holds the optimistic value', () => {
107
+ expect(inverseOf(false, true)(true)).toBe(false);
108
+ expect(inverseOf(1, 2)(3)).toBe(3);
109
+ });
110
+
111
+ test('counterRevert gives back the difference, whatever happened since', () => {
112
+ expect(counterRevert(8, 10, 9)).toBe(9);
113
+ });
114
+ });