cursedbelt 5.89.0 → 5.91.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 (73) 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/components/DataTable/DataTable.d.ts +14 -1
  6. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  7. package/dist/react/components/DataTable/DataTable.js +18 -7
  8. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  9. package/dist/react/components/Drawer.d.ts +8 -1
  10. package/dist/react/components/Drawer.d.ts.map +1 -1
  11. package/dist/react/components/Drawer.js +2 -2
  12. package/dist/react/components/Drawer.js.map +1 -1
  13. package/dist/react/components/Page.d.ts +9 -1
  14. package/dist/react/components/Page.d.ts.map +1 -1
  15. package/dist/react/components/Page.js +3 -2
  16. package/dist/react/components/Page.js.map +1 -1
  17. package/dist/react/createApiHooks.d.ts +30 -0
  18. package/dist/react/createApiHooks.d.ts.map +1 -1
  19. package/dist/react/createApiHooks.js +31 -2
  20. package/dist/react/createApiHooks.js.map +1 -1
  21. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  22. package/dist/react/file-tree/FileTree.js +9 -3
  23. package/dist/react/file-tree/FileTree.js.map +1 -1
  24. package/dist/react/index.d.ts +2 -0
  25. package/dist/react/index.d.ts.map +1 -1
  26. package/dist/react/index.js +2 -0
  27. package/dist/react/index.js.map +1 -1
  28. package/dist/react/nav/RailPlaces.d.ts +6 -2
  29. package/dist/react/nav/RailPlaces.d.ts.map +1 -1
  30. package/dist/react/nav/RailPlaces.js +1 -1
  31. package/dist/react/nav/RailPlaces.js.map +1 -1
  32. package/dist/react/optimistic/index.d.ts +10 -0
  33. package/dist/react/optimistic/index.d.ts.map +1 -0
  34. package/dist/react/optimistic/index.js +22 -0
  35. package/dist/react/optimistic/index.js.map +1 -0
  36. package/dist/react/optimistic/optimistic.d.ts +159 -0
  37. package/dist/react/optimistic/optimistic.d.ts.map +1 -0
  38. package/dist/react/optimistic/optimistic.js +216 -0
  39. package/dist/react/optimistic/optimistic.js.map +1 -0
  40. package/dist/react/optimistic/revert.d.ts +47 -0
  41. package/dist/react/optimistic/revert.d.ts.map +1 -0
  42. package/dist/react/optimistic/revert.js +235 -0
  43. package/dist/react/optimistic/revert.js.map +1 -0
  44. package/dist/styles-areas/analytics.css +5 -5
  45. package/dist/styles-areas/data-table.css +5 -5
  46. package/dist/styles-utilities.css +10 -0
  47. package/package.json +1 -1
  48. package/scripts/styleAreas.ts +3 -0
  49. package/src/react/LazyToaster.spec.tsx +18 -0
  50. package/src/react/LazyToaster.tsx +26 -0
  51. package/src/react/components/DataTable/DataTable.tsx +37 -7
  52. package/src/react/components/DataTable/columnHeaders.spec.tsx +83 -0
  53. package/src/react/components/Drawer.tsx +13 -0
  54. package/src/react/components/Modal.spec.tsx +14 -0
  55. package/src/react/components/Page.spec.tsx +20 -0
  56. package/src/react/components/Page.tsx +35 -14
  57. package/src/react/createApiHooks.spec.tsx +77 -0
  58. package/src/react/createApiHooks.ts +58 -1
  59. package/src/react/file-tree/FileTree.spec.tsx +14 -0
  60. package/src/react/file-tree/FileTree.tsx +9 -3
  61. package/src/react/index.ts +2 -0
  62. package/src/react/nav/RailPlaces.spec.tsx +6 -0
  63. package/src/react/nav/RailPlaces.tsx +12 -2
  64. package/src/react/optimistic/index.ts +36 -0
  65. package/src/react/optimistic/optimistic.spec.tsx +225 -0
  66. package/src/react/optimistic/optimistic.ts +326 -0
  67. package/src/react/optimistic/queryTarget.spec.ts +64 -0
  68. package/src/react/optimistic/revert.spec.ts +114 -0
  69. package/src/react/optimistic/revert.ts +238 -0
  70. package/src/react/optimistic/staysOffTheEntry.spec.ts +38 -0
  71. package/src/styles-areas/analytics.css +5 -5
  72. package/src/styles-areas/data-table.css +5 -5
  73. package/src/styles-utilities.css +10 -0
@@ -757,6 +757,7 @@ export { Wizard } from './components/Wizard/Wizard.js';
757
757
  export * from './confirm.js';
758
758
  export { VIEWPORT_META } from './constants/viewport.js';
759
759
  export * from './createApiHooks.js';
760
+ export * from './optimistic/index.js';
760
761
  export {
761
762
  EmojiAutocomplete,
762
763
  type EmojiAutocompleteProps,
@@ -960,6 +961,7 @@ export * from './nav/index.js';
960
961
  export * from './palette/index.js';
961
962
  export * from './prompt.js';
962
963
  export { sessionAuthHeaders } from './sessionAuth.js';
964
+ export { LazyToaster } from './LazyToaster.js';
963
965
  export { Toaster, type ToasterProps, toast } from './Toast.js';
964
966
  export { TestReportViewer, type TestReportViewerProps } from './testReport/index.js';
965
967
  export { UiScaleControl, type UiScaleControlProps } from './uiScale/UiScaleControl.js';
@@ -26,6 +26,12 @@ describe('RailPlaces — the top bar moved into the sidebar (owner, 2026-10-20)'
26
26
  expect(getByRole('link', { name: 'Stats · 3' }).getAttribute('href')).toBe('#/stats');
27
27
  });
28
28
 
29
+ test('no brand (a drawer titled with the app): the places alone, no brand line', () => {
30
+ const { container, getByRole } = render(<RailPlaces items={items} />);
31
+ expect(container.querySelector('[data-cb-rail-brand]')).toBeNull();
32
+ expect(getByRole('link', { name: 'Library' }).getAttribute('href')).toBe('#/');
33
+ });
34
+
29
35
  test('the current place is marked three ways (NAV-6): aria-current, the raised fill, the 3px accent edge', () => {
30
36
  const { getByRole } = render(<RailPlaces brand={brand} items={items} />);
31
37
  const library = getByRole('link', { name: 'Library' });
@@ -6,8 +6,12 @@ import { NAV_BADGE_TONE } from './navShared.js';
6
6
  import type { TopBarItem, TopBarProps } from './TopBar.js';
7
7
 
8
8
  export interface RailPlacesProps {
9
- /** The app's mark and name, linking to its root — the same shape `TopBar` takes. */
10
- brand: TopBarProps['brand'];
9
+ /**
10
+ * The app's mark and name, linking to its root — the same shape `TopBar` takes. Absent where the
11
+ * sidebar's container already names the app (a phone's drawer, titled with it, whose title line
12
+ * carries the utilities too — `Drawer`'s `actions`): then only the places are drawn.
13
+ */
14
+ brand?: TopBarProps['brand'];
11
15
  /** The app's places, in IA order — the same items a `TopBar` would have drawn. */
12
16
  items: readonly TopBarItem[];
13
17
  /**
@@ -67,6 +71,7 @@ export function RailPlaces({
67
71
  data-cb-rail-places=''
68
72
  className={cn('flex flex-none flex-col gap-1 border-b border-rule px-2 pt-2 pb-2', className)}
69
73
  >
74
+ {brand ? (
70
75
  <div className={cn('flex h-10 min-w-0 items-center gap-1', collapsed && 'justify-center')}>
71
76
  <Brand
72
77
  {...brand.linkProps}
@@ -96,6 +101,11 @@ export function RailPlaces({
96
101
  </div>
97
102
  ) : null}
98
103
  </div>
104
+ ) : trailing && !collapsed ? (
105
+ <div data-cb-rail-utilities='' data-cb-nav-utility='' className='flex h-10 items-center justify-end gap-0.5'>
106
+ {trailing}
107
+ </div>
108
+ ) : null}
99
109
  {trailing && collapsed ? (
100
110
  <div data-cb-rail-utilities='' data-cb-nav-utility='' className='flex flex-col items-center gap-0.5'>
101
111
  {trailing}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Optimistic changes — the UI does what the user asked at once, the server is told after, and
3
+ * only a refusal takes it back (with a Retry toast). `optimistic.ts` has the contract; the rule
4
+ * for WHEN to use it is STA-10 in the design law (`docs/design/states-and-feedback.md`).
5
+ */
6
+ import { useSyncExternalStore } from 'react';
7
+ import { type PendingChange, pendingChanges, subscribePendingChanges } from './optimistic.js';
8
+
9
+ export {
10
+ type OptimisticOptions,
11
+ type OptimisticOutcome,
12
+ type OptimisticQueryClient,
13
+ type OptimisticStep,
14
+ type OptimisticTarget,
15
+ optimistic,
16
+ type PendingChange,
17
+ pendingChanges,
18
+ queryTarget,
19
+ subscribePendingChanges,
20
+ target,
21
+ whenSettled,
22
+ } from './optimistic.js';
23
+ export { counterRevert } from './revert.js';
24
+
25
+ const none: readonly PendingChange[] = [];
26
+
27
+ /**
28
+ * The optimistic changes still waiting on the server — all of them, or those under one `key`.
29
+ * For a quiet "Saving…" beside a record, or a sync dot in the shell; never a spinner that holds
30
+ * the user back, since the change has already happened on screen.
31
+ */
32
+ export function usePendingChanges(key?: string): readonly PendingChange[] {
33
+ const all = useSyncExternalStore(subscribePendingChanges, pendingChanges, () => none);
34
+ if (key === undefined) return all;
35
+ return all.filter((change) => change.key === key);
36
+ }
@@ -0,0 +1,225 @@
1
+ import { afterEach, beforeEach, describe, expect, type Mock, spyOn, test } from 'bun:test';
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { useState } from 'react';
4
+ import { toast } from '../Toast.js';
5
+ import { counterRevert, optimistic, pendingChanges, queryTarget, usePendingChanges, whenSettled } from './index.js';
6
+
7
+ // A spy on the real toast, restored after each test — `mock.module` would outlive this file.
8
+ let toastError: Mock<typeof toast.error>;
9
+ beforeEach(() => {
10
+ toastError = spyOn(toast, 'error').mockImplementation(() => 0);
11
+ });
12
+ afterEach(() => toastError.mockRestore());
13
+
14
+ type Row = { id: string; title: string };
15
+ const rows: Row[] = [
16
+ { id: 'a', title: 'A' },
17
+ { id: 'b', title: 'B' },
18
+ { id: 'c', title: 'C' },
19
+ ];
20
+
21
+ /** A promise the test settles by hand — the server's answer, on the test's schedule. */
22
+ const deferred = <T,>() => {
23
+ let resolve!: (value: T) => void;
24
+ let reject!: (error: unknown) => void;
25
+ const promise = new Promise<T>((res, rej) => {
26
+ resolve = res;
27
+ reject = rej;
28
+ });
29
+ return { promise, resolve, reject };
30
+ };
31
+
32
+ /** Plain state with a setState-shaped setter, standing in for a store. */
33
+ const box = <S,>(initial: S) => {
34
+ const b = {
35
+ value: initial,
36
+ set: (updater: (s: S) => S) => {
37
+ b.value = updater(b.value);
38
+ },
39
+ };
40
+ return b;
41
+ };
42
+
43
+ describe('optimistic', () => {
44
+ test('the change shows BEFORE the request settles, and stays when it succeeds', async () => {
45
+ const state = box(rows);
46
+ const server = deferred<string>();
47
+ const done = optimistic({
48
+ label: 'delete “B”',
49
+ targets: { set: state.set, apply: (r: Row[]) => r.filter((x) => x.id !== 'b') },
50
+ request: () => server.promise,
51
+ });
52
+ expect(state.value.map((r) => r.id)).toEqual(['a', 'c']);
53
+ expect(pendingChanges()).toHaveLength(1);
54
+ server.resolve('ok');
55
+ expect(await done).toEqual({ ok: true, value: 'ok' });
56
+ expect(state.value.map((r) => r.id)).toEqual(['a', 'c']);
57
+ expect(pendingChanges()).toHaveLength(0);
58
+ expect(toastError).not.toHaveBeenCalled();
59
+ });
60
+
61
+ test('a refusal reverts only its own change, toasts with Retry, and Retry tries again', async () => {
62
+ const state = box(rows);
63
+ const total = box(3);
64
+ const first = deferred<void>();
65
+ const failing = optimistic({
66
+ label: 'delete “A”',
67
+ targets: [
68
+ { set: state.set, apply: (r: Row[]) => r.filter((x) => x.id !== 'a') },
69
+ { set: total.set, apply: (n: number) => n - 1, revert: counterRevert },
70
+ ],
71
+ request: () => first.promise,
72
+ });
73
+ const second = optimistic({
74
+ label: 'delete “B”',
75
+ targets: [
76
+ { set: state.set, apply: (r: Row[]) => r.filter((x) => x.id !== 'b') },
77
+ { set: total.set, apply: (n: number) => n - 1, revert: counterRevert },
78
+ ],
79
+ request: () => Promise.resolve(),
80
+ });
81
+ await second;
82
+ first.reject(new Error('the server said no'));
83
+ const outcome = await failing;
84
+ expect(outcome.ok).toBe(false);
85
+ expect(state.value.map((r) => r.id)).toEqual(['a', 'c']);
86
+ expect(total.value).toBe(2);
87
+
88
+ expect(toastError).toHaveBeenCalledTimes(1);
89
+ const [message, data] = toastError.mock.calls[0] as unknown as [string, { description: string; action: { label: string; onClick: () => void } }];
90
+ expect(message).toBe('Couldn’t delete “A”');
91
+ expect(data.description).toBe('the server said no');
92
+ expect(data.action.label).toBe('Retry');
93
+ });
94
+
95
+ test('a step (imperative now/undo) runs at once and undoes on a refusal', async () => {
96
+ const log: string[] = [];
97
+ await optimistic({
98
+ label: 'favorite',
99
+ toast: false,
100
+ targets: { now: () => log.push('now'), undo: () => log.push('undo') },
101
+ request: () => Promise.reject(new Error('no')),
102
+ });
103
+ expect(log).toEqual(['now', 'undo']);
104
+ });
105
+
106
+ test('a rollback skips state that belongs to another view by now', async () => {
107
+ const state = box(rows);
108
+ let view = 'folder-1';
109
+ const server = deferred<void>();
110
+ const done = optimistic({
111
+ label: 'delete “A”',
112
+ toast: false,
113
+ targets: { set: state.set, apply: (r: Row[]) => r.filter((x) => x.id !== 'a'), scope: () => view },
114
+ request: () => server.promise,
115
+ });
116
+ view = 'folder-2';
117
+ state.value = [{ id: 'z', title: 'Z' }];
118
+ server.reject(new Error('no'));
119
+ await done;
120
+ expect(state.value.map((r) => r.id)).toEqual(['z']);
121
+ });
122
+
123
+ test('requests under one key reach the server in order; the UI never waits for the queue', async () => {
124
+ const state = box({ title: 'A' });
125
+ const order: string[] = [];
126
+ const gate = deferred<void>();
127
+ const one = optimistic({
128
+ label: 'rename',
129
+ key: 'row-a',
130
+ targets: { set: state.set, apply: (s: { title: string }) => ({ ...s, title: 'X' }) },
131
+ request: async () => {
132
+ order.push('X sent');
133
+ await gate.promise;
134
+ order.push('X done');
135
+ },
136
+ });
137
+ const two = optimistic({
138
+ label: 'rename',
139
+ key: 'row-a',
140
+ targets: { set: state.set, apply: (s: { title: string }) => ({ ...s, title: 'Y' }) },
141
+ request: async () => {
142
+ order.push('Y sent');
143
+ },
144
+ });
145
+ expect(state.value.title).toBe('Y');
146
+ await Promise.resolve();
147
+ expect(order).toEqual(['X sent']);
148
+ gate.resolve();
149
+ await Promise.all([one, two]);
150
+ expect(order).toEqual(['X sent', 'X done', 'Y sent']);
151
+ });
152
+
153
+ test('the page asks before closing while a change is unsent, and stops asking after', async () => {
154
+ const server = deferred<void>();
155
+ const done = optimistic({ label: 'save', request: () => server.promise, toast: false });
156
+ const event = new Event('beforeunload', { cancelable: true });
157
+ window.dispatchEvent(event);
158
+ expect(event.defaultPrevented).toBe(true);
159
+ server.resolve();
160
+ await done;
161
+ const later = new Event('beforeunload', { cancelable: true });
162
+ window.dispatchEvent(later);
163
+ expect(later.defaultPrevented).toBe(false);
164
+ });
165
+
166
+ test('works through a React setter, and usePendingChanges follows the request', async () => {
167
+ const server = deferred<void>();
168
+ const { result } = renderHook(() => {
169
+ const [list, setList] = useState(rows);
170
+ return { list, setList, pending: usePendingChanges('row-b') };
171
+ });
172
+ let done!: Promise<unknown>;
173
+ act(() => {
174
+ done = optimistic({
175
+ label: 'delete “B”',
176
+ key: 'row-b',
177
+ targets: { set: result.current.setList, apply: (r: Row[]) => r.filter((x) => x.id !== 'b') },
178
+ request: () => server.promise,
179
+ });
180
+ });
181
+ expect(result.current.list.map((r) => r.id)).toEqual(['a', 'c']);
182
+ expect(result.current.pending).toHaveLength(1);
183
+ await act(async () => {
184
+ server.reject(new Error('no'));
185
+ await done;
186
+ });
187
+ expect(result.current.list.map((r) => r.id)).toEqual(['a', 'b', 'c']);
188
+ expect(result.current.pending).toHaveLength(0);
189
+ });
190
+
191
+ test('queryTarget cancels the in-flight fetch, writes the cache, and refetches on settle', async () => {
192
+ const calls: string[] = [];
193
+ let data: unknown = rows;
194
+ const client = {
195
+ cancelQueries: () => void calls.push('cancel'),
196
+ setQueryData: (_k: readonly unknown[], updater: (old: unknown) => unknown) => {
197
+ data = updater(data);
198
+ calls.push('set');
199
+ },
200
+ invalidateQueries: () => void calls.push('invalidate'),
201
+ };
202
+ await optimistic({
203
+ label: 'delete “C”',
204
+ targets: { ...queryTarget<Row[]>(client, ['rows']), apply: (r: Row[]) => r.filter((x) => x.id !== 'c') },
205
+ request: () => Promise.resolve(),
206
+ });
207
+ expect((data as Row[]).map((r) => r.id)).toEqual(['a', 'b']);
208
+ expect(calls).toEqual(['cancel', 'set', 'invalidate']);
209
+ });
210
+
211
+ test('whenSettled resolves once nothing is pending', async () => {
212
+ const server = deferred<void>();
213
+ const done = optimistic({ label: 'save', toast: false, request: () => server.promise });
214
+ let quiet = false;
215
+ const waiting = whenSettled().then(() => {
216
+ quiet = true;
217
+ });
218
+ await Promise.resolve();
219
+ expect(quiet).toBe(false);
220
+ server.resolve();
221
+ await done;
222
+ await waiting;
223
+ expect(quiet).toBe(true);
224
+ });
225
+ });
@@ -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
+ }