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,77 @@
1
+ import { afterEach, beforeEach, describe, expect, type Mock, spyOn, test } from 'bun:test';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { act, renderHook } from '@testing-library/react';
4
+ import type { ReactNode } from 'react';
5
+ import { createApiHooks, OptimisticFailure } from './createApiHooks.js';
6
+ import { toast } from './Toast.js';
7
+
8
+ type Rom = { id: string; favorite: boolean };
9
+
10
+ let toastError: Mock<typeof toast.error>;
11
+ beforeEach(() => {
12
+ toastError = spyOn(toast, 'error').mockImplementation(() => 0);
13
+ });
14
+ afterEach(() => toastError.mockRestore());
15
+
16
+ const setup = (request: (rom: Rom) => Promise<void>) => {
17
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
18
+ queryClient.setQueryData<Rom[]>(['roms'], [{ id: 'r1', favorite: false }]);
19
+ const appToaster = { success: () => undefined, error: () => undefined };
20
+ const errorSpy = spyOn(appToaster, 'error');
21
+ const { useApiMutation } = createApiHooks({ useClient: () => null, useToaster: () => appToaster });
22
+ const wrapper = ({ children }: { children: ReactNode }) => (
23
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
24
+ );
25
+ const { result } = renderHook(
26
+ () =>
27
+ useApiMutation<Rom, void>({
28
+ mutationFn: (_client, rom) => request(rom),
29
+ optimistic: {
30
+ label: (rom) => `favorite ${rom.id}`,
31
+ edits: (rom) => [
32
+ {
33
+ queryKey: ['roms'],
34
+ apply: (roms: Rom[]) => roms.map((r) => (r.id === rom.id ? { ...r, favorite: !r.favorite } : r)),
35
+ },
36
+ ],
37
+ },
38
+ }),
39
+ { wrapper },
40
+ );
41
+ return { queryClient, result, errorSpy };
42
+ };
43
+
44
+ describe('useApiMutation — optimistic', () => {
45
+ test('the cache changes before the request settles', async () => {
46
+ let finish!: () => void;
47
+ const { queryClient, result } = setup(() => new Promise<void>((resolve) => (finish = resolve)));
48
+ let done!: Promise<void>;
49
+ act(() => {
50
+ done = result.current.mutateAsync({ id: 'r1', favorite: false });
51
+ });
52
+ await act(async () => {
53
+ await Promise.resolve();
54
+ await Promise.resolve();
55
+ });
56
+ expect(queryClient.getQueryData<Rom[]>(['roms'])?.[0]?.favorite).toBe(true);
57
+ await act(async () => {
58
+ finish();
59
+ await done;
60
+ });
61
+ });
62
+
63
+ test('a refusal reverts the cache, shows ONE Retry toast, and rejects with the cause', async () => {
64
+ const { queryClient, result, errorSpy } = setup(() => Promise.reject(new Error('nope')));
65
+ let caught: unknown;
66
+ await act(async () => {
67
+ await result.current.mutateAsync({ id: 'r1', favorite: false }).catch((e: unknown) => {
68
+ caught = e;
69
+ });
70
+ });
71
+ expect(queryClient.getQueryData<Rom[]>(['roms'])?.[0]?.favorite).toBe(false);
72
+ expect(caught).toBeInstanceOf(OptimisticFailure);
73
+ expect((caught as Error).cause).toBeInstanceOf(Error);
74
+ expect(toastError).toHaveBeenCalledTimes(1);
75
+ expect(errorSpy).not.toHaveBeenCalled();
76
+ });
77
+ });
@@ -6,6 +6,8 @@ import {
6
6
  useQueryClient,
7
7
  } from '@tanstack/react-query';
8
8
  import type { ReactNode } from 'react';
9
+ import { type OptimisticQueryClient, optimistic as runOptimistic, queryTarget } from './optimistic/optimistic.js';
10
+ import type { IdOf } from './optimistic/revert.js';
9
11
 
10
12
  // IMPORTANT: this factory's PUBLIC surface is deliberately free of @tanstack's
11
13
  // class-shaped types (UseQueryResult/UseQueryOptions/QueryClient). Those carry
@@ -121,6 +123,32 @@ export interface ApiMutationConfig<Client, TArgs, TData> {
121
123
  onSuccess?: (data: TData, args: TArgs) => void;
122
124
  /** Extra error handling, in addition to the automatic error toast. */
123
125
  onError?: (error: unknown, args: TArgs) => void;
126
+ /**
127
+ * Show the change before the server answers (STA-10). Each edit is a cache entry and the
128
+ * forward change to its data; the rollback is derived, and a refusal reverts only what this
129
+ * call touched and toasts "Couldn't <label>" with Retry — in place of the plain error toast.
130
+ * Every edited key is refetched once the request settles.
131
+ *
132
+ * optimistic: {
133
+ * label: (rom) => `favorite “${rom.title}”`,
134
+ * edits: (rom) => [{ queryKey: ['roms'], apply: (roms: Rom[]) => roms.map(...) }],
135
+ * }
136
+ */
137
+ optimistic?: ApiOptimistic<TArgs>;
138
+ }
139
+
140
+ export interface ApiOptimistic<TArgs> {
141
+ /** The verb phrase the failure toast completes: "Couldn't " + label. */
142
+ label: string | ((args: TArgs) => string);
143
+ /** The cache entries to change at once, each with its forward edit. */
144
+ edits: (args: TArgs) => ReadonlyArray<{
145
+ queryKey: QueryKey;
146
+ apply: (data: any) => any;
147
+ /** How the entry's array items are told apart when they carry neither `id` nor `key`. */
148
+ idOf?: IdOf;
149
+ }>;
150
+ /** Serialize requests that share a key — e.g. the record's id. */
151
+ key?: (args: TArgs) => string;
124
152
  }
125
153
 
126
154
  export interface ApiHooks<Client> {
@@ -130,6 +158,14 @@ export interface ApiHooks<Client> {
130
158
  ) => ApiMutationResult<TArgs, TData>;
131
159
  }
132
160
 
161
+ /** The rejection an optimistic mutation's `mutateAsync` gives: the server's own error is `cause`. */
162
+ export class OptimisticFailure extends Error {
163
+ constructor(cause: unknown) {
164
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
165
+ this.name = 'OptimisticFailure';
166
+ }
167
+ }
168
+
133
169
  /**
134
170
  * Build a pair of TanStack-Query hooks bound to your app's request client + toaster.
135
171
  * Standardizes the data-fetching boilerplate every app reinvents: apply a fallback
@@ -182,12 +218,28 @@ export const createApiHooks = <Client>({
182
218
  invalidate,
183
219
  onSuccess,
184
220
  onError,
221
+ optimistic,
185
222
  }: ApiMutationConfig<Client, TArgs, TData>): ApiMutationResult<TArgs, TData> => {
186
223
  const client = useClient();
187
224
  const queryClient = useQueryClient();
188
225
  const toaster = useToaster?.();
189
226
  return useMutation<TData, unknown, TArgs>({
190
- mutationFn: (args) => mutationFn(client, args),
227
+ mutationFn: optimistic
228
+ ? async (args) => {
229
+ const outcome = await runOptimistic({
230
+ label: typeof optimistic.label === 'function' ? optimistic.label(args) : optimistic.label,
231
+ key: optimistic.key?.(args),
232
+ targets: optimistic.edits(args).map(({ queryKey, apply, idOf }) => ({
233
+ ...queryTarget(queryClient as unknown as OptimisticQueryClient, queryKey),
234
+ apply,
235
+ ...(idOf ? { idOf } : {}),
236
+ })),
237
+ request: () => mutationFn(client, args),
238
+ });
239
+ if (!outcome.ok) throw new OptimisticFailure(outcome.error);
240
+ return outcome.value;
241
+ }
242
+ : (args) => mutationFn(client, args),
191
243
  onSuccess: (data, args) => {
192
244
  if (successToast && toaster) {
193
245
  toaster.success(
@@ -201,6 +253,11 @@ export const createApiHooks = <Client>({
201
253
  onSuccess?.(data, args);
202
254
  },
203
255
  onError: (error, args) => {
256
+ // An optimistic call has already reverted and shown its own Retry toast.
257
+ if (error instanceof OptimisticFailure) {
258
+ onError?.(error.cause, args);
259
+ return;
260
+ }
204
261
  toaster?.error(error);
205
262
  onError?.(error, args);
206
263
  },
@@ -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';
@@ -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
+ });