cursedbelt 5.87.1 → 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 (50) 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/sharing/ShareEditor.d.ts +5 -1
  6. package/dist/react/components/sharing/ShareEditor.d.ts.map +1 -1
  7. package/dist/react/components/sharing/ShareEditor.js +2 -2
  8. package/dist/react/components/sharing/ShareEditor.js.map +1 -1
  9. package/dist/react/createApiHooks.d.ts +30 -0
  10. package/dist/react/createApiHooks.d.ts.map +1 -1
  11. package/dist/react/createApiHooks.js +31 -2
  12. package/dist/react/createApiHooks.js.map +1 -1
  13. package/dist/react/icons.d.ts +7 -0
  14. package/dist/react/icons.d.ts.map +1 -1
  15. package/dist/react/icons.js +7 -0
  16. package/dist/react/icons.js.map +1 -1
  17. package/dist/react/index.d.ts +2 -0
  18. package/dist/react/index.d.ts.map +1 -1
  19. package/dist/react/index.js +2 -0
  20. package/dist/react/index.js.map +1 -1
  21. package/dist/react/optimistic/index.d.ts +10 -0
  22. package/dist/react/optimistic/index.d.ts.map +1 -0
  23. package/dist/react/optimistic/index.js +22 -0
  24. package/dist/react/optimistic/index.js.map +1 -0
  25. package/dist/react/optimistic/optimistic.d.ts +159 -0
  26. package/dist/react/optimistic/optimistic.d.ts.map +1 -0
  27. package/dist/react/optimistic/optimistic.js +216 -0
  28. package/dist/react/optimistic/optimistic.js.map +1 -0
  29. package/dist/react/optimistic/revert.d.ts +47 -0
  30. package/dist/react/optimistic/revert.d.ts.map +1 -0
  31. package/dist/react/optimistic/revert.js +235 -0
  32. package/dist/react/optimistic/revert.js.map +1 -0
  33. package/package.json +1 -1
  34. package/scripts/styleAreas.ts +3 -0
  35. package/src/react/LazyToaster.spec.tsx +18 -0
  36. package/src/react/LazyToaster.tsx +26 -0
  37. package/src/react/components/sharing/ShareEditor.spec.tsx +15 -0
  38. package/src/react/components/sharing/ShareEditor.tsx +13 -2
  39. package/src/react/createApiHooks.spec.tsx +77 -0
  40. package/src/react/createApiHooks.ts +58 -1
  41. package/src/react/icons.tsx +7 -0
  42. package/src/react/index.ts +2 -0
  43. package/src/react/media/playerStrip.spec.tsx +8 -4
  44. package/src/react/optimistic/index.ts +36 -0
  45. package/src/react/optimistic/optimistic.spec.tsx +225 -0
  46. package/src/react/optimistic/optimistic.ts +326 -0
  47. package/src/react/optimistic/queryTarget.spec.ts +64 -0
  48. package/src/react/optimistic/revert.spec.ts +114 -0
  49. package/src/react/optimistic/revert.ts +238 -0
  50. package/src/react/optimistic/staysOffTheEntry.spec.ts +38 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The rollback half of an optimistic change, DERIVED from the change itself.
3
+ *
4
+ * A caller writes only the forward edit (`rows => rows.filter(r => r.id !== id)`); this works out
5
+ * what that edit touched by comparing the state before and after it, and returns an inverse that
6
+ * undoes exactly that — applied to whatever the state is when the request fails, not to a
7
+ * snapshot. That difference is the whole reason this file exists:
8
+ *
9
+ * delete A, delete B, A fails → restoring the snapshot taken before A would bring back B too
10
+ * (or, taken before B, drop a rename made since). The inverse instead puts A back where it
11
+ * was and leaves B gone and the rename standing.
12
+ *
13
+ * The rules, by shape (recursing through plain objects and arrays, so a patch three levels down
14
+ * reverts three levels down):
15
+ *
16
+ * | shape | the change | its inverse, against the current state |
17
+ * |---|---|---|
18
+ * | array of records (`id`) or primitives | item removed | put back after its nearest surviving neighbor, unless it is back already |
19
+ * | | item added | taken out by id |
20
+ * | | item changed | recurse into it by id (only fields still holding the optimistic value revert) |
21
+ * | | reordered | the old order back, only if the current order is still the optimistic one |
22
+ * | plain object | key changed / added / removed | per key, recursing |
23
+ * | `Set` / `Map` | member added / removed | the member out / back |
24
+ * | anything else | replaced | the old value back only if the current one is still the optimistic one |
25
+ *
26
+ * "Only if it still holds the optimistic value" is what lets two edits race: rename to "B", then
27
+ * to "C" — if the first request fails, the title is "C" by now, so it stays "C".
28
+ *
29
+ * Returns the current state UNCHANGED (same reference) when nothing needs reverting, so a React
30
+ * setter given the inverse does not re-render.
31
+ */
32
+ /** How an array item is identified. Records by `id` (or `key`), primitives by value. */
33
+ export type IdOf = (item: unknown) => unknown;
34
+ export declare const defaultIdOf: IdOf;
35
+ /**
36
+ * The inverse of the change `before → after`, as a function of the state at revert time.
37
+ * `idOf` names array items; the default reads `id`, then `key`, and uses a primitive's value.
38
+ */
39
+ export declare function inverseOf<S>(before: S, after: S, idOf?: IdOf): (current: S) => S;
40
+ /**
41
+ * A counter's inverse: give back the difference the change made, whatever has happened since.
42
+ * The whole-value rule is wrong for a count — two deletes take a total 10 → 9 → 8, and when the
43
+ * first fails the total is 8, not 9, so "revert only if still optimistic" would never fire. Pass
44
+ * it as a target's `revert`: `{ set: setTotal, apply: (n) => n - 1, revert: counterRevert }`.
45
+ */
46
+ export declare const counterRevert: (current: number, before: number, after: number) => number;
47
+ //# sourceMappingURL=revert.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revert.d.ts","sourceRoot":"","sources":["../../../src/react/optimistic/revert.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,wFAAwF;AACxF,MAAM,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;AAI9C,eAAO,MAAM,WAAW,EAAE,IAMzB,CAAC;AAQF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,GAAE,IAAkB,GAAG,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,CAE7F;AA6KD;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,EAAE,QAAQ,MAAM,EAAE,OAAO,MAAM,KAAG,MACnD,CAAC"}
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The rollback half of an optimistic change, DERIVED from the change itself.
3
+ *
4
+ * A caller writes only the forward edit (`rows => rows.filter(r => r.id !== id)`); this works out
5
+ * what that edit touched by comparing the state before and after it, and returns an inverse that
6
+ * undoes exactly that — applied to whatever the state is when the request fails, not to a
7
+ * snapshot. That difference is the whole reason this file exists:
8
+ *
9
+ * delete A, delete B, A fails → restoring the snapshot taken before A would bring back B too
10
+ * (or, taken before B, drop a rename made since). The inverse instead puts A back where it
11
+ * was and leaves B gone and the rename standing.
12
+ *
13
+ * The rules, by shape (recursing through plain objects and arrays, so a patch three levels down
14
+ * reverts three levels down):
15
+ *
16
+ * | shape | the change | its inverse, against the current state |
17
+ * |---|---|---|
18
+ * | array of records (`id`) or primitives | item removed | put back after its nearest surviving neighbor, unless it is back already |
19
+ * | | item added | taken out by id |
20
+ * | | item changed | recurse into it by id (only fields still holding the optimistic value revert) |
21
+ * | | reordered | the old order back, only if the current order is still the optimistic one |
22
+ * | plain object | key changed / added / removed | per key, recursing |
23
+ * | `Set` / `Map` | member added / removed | the member out / back |
24
+ * | anything else | replaced | the old value back only if the current one is still the optimistic one |
25
+ *
26
+ * "Only if it still holds the optimistic value" is what lets two edits race: rename to "B", then
27
+ * to "C" — if the first request fails, the title is "C" by now, so it stays "C".
28
+ *
29
+ * Returns the current state UNCHANGED (same reference) when nothing needs reverting, so a React
30
+ * setter given the inverse does not re-render.
31
+ */
32
+ const NO_ID = Symbol('no-id');
33
+ export const defaultIdOf = (item) => {
34
+ if (item === null || typeof item !== 'object')
35
+ return item;
36
+ const record = item;
37
+ if (record.id !== undefined)
38
+ return record.id;
39
+ if (record.key !== undefined)
40
+ return record.key;
41
+ return NO_ID;
42
+ };
43
+ const isPlainObject = (value) => {
44
+ if (value === null || typeof value !== 'object')
45
+ return false;
46
+ const proto = Object.getPrototypeOf(value);
47
+ return proto === Object.prototype || proto === null;
48
+ };
49
+ /**
50
+ * The inverse of the change `before → after`, as a function of the state at revert time.
51
+ * `idOf` names array items; the default reads `id`, then `key`, and uses a primitive's value.
52
+ */
53
+ export function inverseOf(before, after, idOf = defaultIdOf) {
54
+ return (current) => revertValue(before, after, current, idOf);
55
+ }
56
+ function revertValue(before, after, current, idOf) {
57
+ if (Object.is(before, after))
58
+ return current;
59
+ if (Array.isArray(before) && Array.isArray(after) && Array.isArray(current)) {
60
+ const reverted = revertArray(before, after, current, idOf);
61
+ if (reverted !== undefined)
62
+ return reverted;
63
+ }
64
+ if (before instanceof Set && after instanceof Set && current instanceof Set) {
65
+ return revertSet(before, after, current);
66
+ }
67
+ if (before instanceof Map && after instanceof Map && current instanceof Map) {
68
+ return revertMap(before, after, current, idOf);
69
+ }
70
+ if (isPlainObject(before) && isPlainObject(after) && isPlainObject(current)) {
71
+ return revertObject(before, after, current, idOf);
72
+ }
73
+ return Object.is(current, after) ? before : current;
74
+ }
75
+ function revertObject(before, after, current, idOf) {
76
+ let out;
77
+ const write = (key, value, remove = false) => {
78
+ out ??= { ...current };
79
+ if (remove)
80
+ delete out[key];
81
+ else
82
+ out[key] = value;
83
+ };
84
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
85
+ const had = Object.hasOwn(before, key);
86
+ const has = Object.hasOwn(after, key);
87
+ if (had && has) {
88
+ if (Object.is(before[key], after[key]))
89
+ continue;
90
+ if (!Object.hasOwn(current, key))
91
+ continue; // removed since — not ours to bring back
92
+ const next = revertValue(before[key], after[key], current[key], idOf);
93
+ if (!Object.is(next, current[key]))
94
+ write(key, next);
95
+ }
96
+ else if (has) {
97
+ // Added by the change: take it out if it is still what the change put there.
98
+ if (Object.hasOwn(current, key) && Object.is(current[key], after[key]))
99
+ write(key, undefined, true);
100
+ }
101
+ else if (!Object.hasOwn(current, key)) {
102
+ write(key, before[key]); // removed by the change, and still gone
103
+ }
104
+ }
105
+ return out ?? current;
106
+ }
107
+ /** `undefined` when the items cannot be identified — the caller falls back to whole-value. */
108
+ function revertArray(before, after, current, idOf) {
109
+ const ids = (list) => list.map(idOf);
110
+ const beforeIds = ids(before);
111
+ const afterIds = ids(after);
112
+ const unique = (list) => !list.includes(NO_ID) && new Set(list).size === list.length;
113
+ if (!unique(beforeIds) || !unique(afterIds))
114
+ return undefined;
115
+ const beforeById = new Map(beforeIds.map((id, i) => [id, before[i]]));
116
+ const afterById = new Map(afterIds.map((id, i) => [id, after[i]]));
117
+ let out = current;
118
+ let touched = false;
119
+ const edit = () => {
120
+ if (!touched) {
121
+ out = [...current];
122
+ touched = true;
123
+ }
124
+ };
125
+ // 1 · Items the change ADDED go, if they are still there.
126
+ const added = afterIds.filter((id) => !beforeById.has(id));
127
+ if (added.length > 0) {
128
+ const drop = new Set(added);
129
+ const kept = out.filter((item) => !drop.has(idOf(item)));
130
+ if (kept.length !== out.length) {
131
+ out = kept;
132
+ touched = true;
133
+ }
134
+ }
135
+ // 2 · Items the change CHANGED revert field by field.
136
+ for (const [id, was] of beforeById) {
137
+ const now = afterById.get(id);
138
+ if (now === undefined || Object.is(was, now))
139
+ continue;
140
+ const at = out.findIndex((item) => Object.is(idOf(item), id));
141
+ if (at < 0)
142
+ continue;
143
+ const next = revertValue(was, now, out[at], idOf);
144
+ if (!Object.is(next, out[at])) {
145
+ edit();
146
+ out[at] = next;
147
+ }
148
+ }
149
+ // 3 · Items the change REMOVED come back beside the neighbor they had.
150
+ const removed = beforeIds.flatMap((id, index) => (afterById.has(id) ? [] : [{ id, index }]));
151
+ for (const { id, index } of removed) {
152
+ if (out.some((item) => Object.is(idOf(item), id)))
153
+ continue;
154
+ edit();
155
+ let at = 0;
156
+ for (let i = index - 1; i >= 0; i--) {
157
+ const neighbor = out.findIndex((item) => Object.is(idOf(item), beforeIds[i]));
158
+ if (neighbor >= 0) {
159
+ at = neighbor + 1;
160
+ break;
161
+ }
162
+ }
163
+ out.splice(at, 0, beforeById.get(id));
164
+ }
165
+ // 4 · A REORDER: the old order back, only where the optimistic order still stands.
166
+ const kept = beforeIds.filter((id) => afterById.has(id));
167
+ const keptAfter = afterIds.filter((id) => beforeById.has(id));
168
+ if (kept.some((id, i) => !Object.is(id, keptAfter[i]))) {
169
+ const keep = new Set(kept);
170
+ const positions = [];
171
+ out.forEach((item, i) => {
172
+ if (keep.has(idOf(item)))
173
+ positions.push(i);
174
+ });
175
+ const nowOrder = positions.map((i) => idOf(out[i]));
176
+ if (nowOrder.length === keptAfter.length && nowOrder.every((id, i) => Object.is(id, keptAfter[i]))) {
177
+ const byId = new Map(positions.map((i) => [idOf(out[i]), out[i]]));
178
+ edit();
179
+ positions.forEach((slot, i) => {
180
+ out[slot] = byId.get(kept[i]);
181
+ });
182
+ }
183
+ }
184
+ return touched ? out : current;
185
+ }
186
+ function revertSet(before, after, current) {
187
+ let out;
188
+ const copy = () => {
189
+ out ??= new Set(current);
190
+ return out;
191
+ };
192
+ for (const member of after) {
193
+ if (!before.has(member) && current.has(member))
194
+ copy().delete(member);
195
+ }
196
+ for (const member of before) {
197
+ if (!after.has(member) && !current.has(member))
198
+ copy().add(member);
199
+ }
200
+ return out ?? current;
201
+ }
202
+ function revertMap(before, after, current, idOf) {
203
+ let out;
204
+ const copy = () => {
205
+ out ??= new Map(current);
206
+ return out;
207
+ };
208
+ for (const key of new Set([...before.keys(), ...after.keys()])) {
209
+ const had = before.has(key);
210
+ const has = after.has(key);
211
+ if (had && has) {
212
+ if (Object.is(before.get(key), after.get(key)) || !current.has(key))
213
+ continue;
214
+ const next = revertValue(before.get(key), after.get(key), current.get(key), idOf);
215
+ if (!Object.is(next, current.get(key)))
216
+ copy().set(key, next);
217
+ }
218
+ else if (has) {
219
+ if (current.has(key) && Object.is(current.get(key), after.get(key)))
220
+ copy().delete(key);
221
+ }
222
+ else if (!current.has(key)) {
223
+ copy().set(key, before.get(key));
224
+ }
225
+ }
226
+ return out ?? current;
227
+ }
228
+ /**
229
+ * A counter's inverse: give back the difference the change made, whatever has happened since.
230
+ * The whole-value rule is wrong for a count — two deletes take a total 10 → 9 → 8, and when the
231
+ * first fails the total is 8, not 9, so "revert only if still optimistic" would never fire. Pass
232
+ * it as a target's `revert`: `{ set: setTotal, apply: (n) => n - 1, revert: counterRevert }`.
233
+ */
234
+ export const counterRevert = (current, before, after) => current + (before - after);
235
+ //# sourceMappingURL=revert.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revert.js","sourceRoot":"","sources":["../../../src/react/optimistic/revert.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAKH,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;AAE9B,MAAM,CAAC,MAAM,WAAW,GAAS,CAAC,IAAI,EAAE,EAAE;IACxC,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3D,MAAM,MAAM,GAAG,IAAuC,CAAC;IACvD,IAAI,MAAM,CAAC,EAAE,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,EAAE,CAAC;IAC9C,IAAI,MAAM,CAAC,GAAG,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,GAAG,CAAC;IAChD,OAAO,KAAK,CAAC;AACf,CAAC,CAAC;AAEF,MAAM,aAAa,GAAG,CAAC,KAAc,EAAoC,EAAE;IACzE,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC9D,MAAM,KAAK,GAAG,MAAM,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;IAC3C,OAAO,KAAK,KAAK,MAAM,CAAC,SAAS,IAAI,KAAK,KAAK,IAAI,CAAC;AACtD,CAAC,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAI,MAAS,EAAE,KAAQ,EAAE,OAAa,WAAW;IACxE,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,CAAM,CAAC;AACrE,CAAC;AAED,SAAS,WAAW,CAAC,MAAe,EAAE,KAAc,EAAE,OAAgB,EAAE,IAAU;IAChF,IAAI,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,OAAO,OAAO,CAAC;IAC7C,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5E,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC;QAC3D,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAO,QAAQ,CAAC;IAC9C,CAAC;IACD,IAAI,MAAM,YAAY,GAAG,IAAI,KAAK,YAAY,GAAG,IAAI,OAAO,YAAY,GAAG,EAAE,CAAC;QAC5E,OAAO,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC;IAC3C,CAAC;IACD,IAAI,MAAM,YAAY,GAAG,IAAI,KAAK,YAAY,GAAG,IAAI,OAAO,YAAY,GAAG,EAAE,CAAC;QAC5E,OAAO,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC;IACjD,CAAC;IACD,IAAI,aAAa,CAAC,MAAM,CAAC,IAAI,aAAa,CAAC,KAAK,CAAC,IAAI,aAAa,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5E,OAAO,YAAY,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;AACtD,CAAC;AAED,SAAS,YAAY,CACnB,MAA+B,EAC/B,KAA8B,EAC9B,OAAgC,EAChC,IAAU;IAEV,IAAI,GAAwC,CAAC;IAC7C,MAAM,KAAK,GAAG,CAAC,GAAW,EAAE,KAAc,EAAE,MAAM,GAAG,KAAK,EAAE,EAAE;QAC5D,GAAG,KAAK,EAAE,GAAG,OAAO,EAAE,CAAC;QACvB,IAAI,MAAM;YAAE,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC;;YACvB,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACxB,CAAC,CAAC;IACF,KAAK,MAAM,GAAG,IAAI,IAAI,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3E,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QACvC,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACtC,IAAI,GAAG,IAAI,GAAG,EAAE,CAAC;YACf,IAAI,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;gBAAE,SAAS;YACjD,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC;gBAAE,SAAS,CAAC,yCAAyC;YACrF,MAAM,IAAI,GAAG,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC;YACtE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC;gBAAE,KAAK,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACvD,CAAC;aAAM,IAAI,GAAG,EAAE,CAAC;YACf,6EAA6E;YAC7E,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;gBAAE,KAAK,CAAC,GAAG,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC;QACtG,CAAC;aAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,EAAE,CAAC;YACxC,KAAK,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,wCAAwC;QACnE,CAAC;IACH,CAAC;IACD,OAAO,GAAG,IAAI,OAAO,CAAC;AACxB,CAAC;AAED,8FAA8F;AAC9F,SAAS,WAAW,CAAC,MAAiB,EAAE,KAAgB,EAAE,OAAkB,EAAE,IAAU;IACtF,MAAM,GAAG,GAAG,CAAC,IAAe,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChD,MAAM,SAAS,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,MAAM,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC;IAC5B,MAAM,MAAM,GAAG,CAAC,IAAe,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,MAAM,CAAC;IAChG,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC;QAAE,OAAO,SAAS,CAAC;IAE9D,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACtE,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACnE,IAAI,GAAG,GAAG,OAAO,CAAC;IAClB,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,GAAG,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC;YACnB,OAAO,GAAG,IAAI,CAAC;QACjB,CAAC;IACH,CAAC,CAAC;IAEF,0DAA0D;IAC1D,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3D,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACrB,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC5B,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACzD,IAAI,IAAI,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM,EAAE,CAAC;YAC/B,GAAG,GAAG,IAAI,CAAC;YACX,OAAO,GAAG,IAAI,CAAC;QACjB,CAAC;IACH,CAAC;IAED,sDAAsD;IACtD,KAAK,MAAM,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,UAAU,EAAE,CAAC;QACnC,MAAM,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC9B,IAAI,GAAG,KAAK,SAAS,IAAI,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,CAAC;YAAE,SAAS;QACvD,MAAM,EAAE,GAAG,GAAG,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;QAC9D,IAAI,EAAE,GAAG,CAAC;YAAE,SAAS;QACrB,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;QAClD,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;YAC9B,IAAI,EAAE,CAAC;YACP,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC;QACjB,CAAC;IACH,CAAC;IAED,uEAAuE;IACvE,MAAM,OAAO,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC;IAC7F,KAAK,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,OAAO,EAAE,CAAC;QACpC,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;YAAE,SAAS;QAC5D,IAAI,EAAE,CAAC;QACP,IAAI,EAAE,GAAG,CAAC,CAAC;QACX,KAAK,IAAI,CAAC,GAAG,KAAK,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YACpC,MAAM,QAAQ,GAAG,GAAG,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC9E,IAAI,QAAQ,IAAI,CAAC,EAAE,CAAC;gBAClB,EAAE,GAAG,QAAQ,GAAG,CAAC,CAAC;gBAClB,MAAM;YACR,CAAC;QACH,CAAC;QACD,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,EAAE,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACxC,CAAC;IAED,mFAAmF;IACnF,MAAM,IAAI,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACzD,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAC9D,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC;QAC3B,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;YACtB,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAAE,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC9C,CAAC,CAAC,CAAC;QACH,MAAM,QAAQ,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACpD,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS,CAAC,MAAM,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACnG,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACnE,IAAI,EAAE,CAAC;YACP,SAAS,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;gBAC5B,GAAG,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YAChC,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC;AACjC,CAAC;AAED,SAAS,SAAS,CAAC,MAAoB,EAAE,KAAmB,EAAE,OAAqB;IACjF,IAAI,GAA6B,CAAC;IAClC,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,GAAG,KAAK,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;QACzB,OAAO,GAAG,CAAC;IACb,CAAC,CAAC;IACF,KAAK,MAAM,MAAM,IAAI,KAAK,EAAE,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,IAAI,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACxE,CAAC;IACD,KAAK,MAAM,MAAM,IAAI,MAAM,EAAE,CAAC;QAC5B,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,IAAI,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACrE,CAAC;IACD,OAAO,GAAG,IAAI,OAAO,CAAC;AACxB,CAAC;AAED,SAAS,SAAS,CAChB,MAA6B,EAC7B,KAA4B,EAC5B,OAA8B,EAC9B,IAAU;IAEV,IAAI,GAAsC,CAAC;IAC3C,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,GAAG,KAAK,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;QACzB,OAAO,GAAG,CAAC;IACb,CAAC,CAAC;IACF,KAAK,MAAM,GAAG,IAAI,IAAI,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,EAAE,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;QAC/D,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5B,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC3B,IAAI,GAAG,IAAI,GAAG,EAAE,CAAC;YACf,IAAI,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC;gBAAE,SAAS;YAC9E,MAAM,IAAI,GAAG,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC;YAClF,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;gBAAE,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QAChE,CAAC;aAAM,IAAI,GAAG,EAAE,CAAC;YACf,IAAI,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;gBAAE,IAAI,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAC1F,CAAC;aAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YAC7B,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QACnC,CAAC;IACH,CAAC;IACD,OAAO,GAAG,IAAI,OAAO,CAAC;AACxB,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,OAAe,EAAE,MAAc,EAAE,KAAa,EAAU,EAAE,CACtF,OAAO,GAAG,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.87.1",
3
+ "version": "5.90.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -122,11 +122,14 @@ export const AREAS: readonly Area[] = [
122
122
  'authSignal.ts',
123
123
  'sessionAuth.ts',
124
124
  'createApiHooks.ts',
125
+ // Draws nothing; `createApiHooks` imports it, and every app's changes go through it (STA-10).
126
+ 'optimistic',
125
127
  'usePagedList.ts',
126
128
  'lazy',
127
129
  'confirm.tsx',
128
130
  'prompt.tsx',
129
131
  'Toast.tsx',
132
+ 'LazyToaster.tsx',
130
133
  'colorScheme',
131
134
  // The look store and its picker: the sibling of colorScheme, installed at boot beside it.
132
135
  'look',
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { act, render } from '@testing-library/react';
3
+ import { LazyToaster } from './LazyToaster.js';
4
+ import { toast } from './Toast.js';
5
+
6
+ describe('LazyToaster', () => {
7
+ test('mounts the stack once its chunk lands, and a toast shows in it', async () => {
8
+ const { container } = render(<LazyToaster />);
9
+ await act(async () => {
10
+ await new Promise((resolve) => setTimeout(resolve, 20));
11
+ });
12
+ await act(async () => {
13
+ toast('Saved');
14
+ await new Promise((resolve) => setTimeout(resolve, 20));
15
+ });
16
+ expect(container.ownerDocument.body.textContent).toContain('Saved');
17
+ });
18
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `<LazyToaster />` — the toast stack, mounted without putting sonner on the entry chunk.
3
+ *
4
+ * Rollup colors a module into the chunk of its static importers, so an app that mounts `Toaster`
5
+ * from its root pays for the whole stack (~15 kB gzip, measured in family, forge task 2900) on
6
+ * first paint, however lazily its toasts are CALLED (`Button`, `optimistic()`). family solved it
7
+ * by hand with a lazy wrapper of its own; flix, roms and station mounted `Toaster` statically the
8
+ * day they first needed a toast (forge task 3583) and their entries grew by it. This is that
9
+ * wrapper, once: the stack's chunk loads after first paint, and a toast raised before it lands
10
+ * waits for it (sonner queues calls made before its Toaster mounts).
11
+ *
12
+ * Takes `Toaster`'s props. Mount it once, at the app's root, in place of `Toaster`.
13
+ */
14
+ import { Suspense } from 'react';
15
+ import { lazyWithReload } from './lazy/staleChunk.js';
16
+ import type { ToasterProps } from './Toast.js';
17
+
18
+ const Stack = lazyWithReload(() => import('./Toast.js').then((m) => ({ default: m.Toaster })));
19
+
20
+ export function LazyToaster(props: ToasterProps) {
21
+ return (
22
+ <Suspense fallback={null}>
23
+ <Stack {...props} />
24
+ </Suspense>
25
+ );
26
+ }
@@ -20,6 +20,21 @@ const groupGrant: ShareGrantRow = {
20
20
  };
21
21
 
22
22
  describe('ShareEditor', () => {
23
+ test('the picker placeholder and the list heading can name what this editor is for', () => {
24
+ const { getByPlaceholderText, getByText, queryByText } = render(
25
+ <ShareEditor
26
+ resource={RESOURCE}
27
+ loadRecipients={loadRecipients}
28
+ onAdd={() => {}}
29
+ placeholder='Add a group…'
30
+ listLabel='Groups with access'
31
+ />,
32
+ );
33
+ expect(getByPlaceholderText('Add a group…')).toBeTruthy();
34
+ expect(getByText('Groups with access')).toBeTruthy();
35
+ expect(queryByText('Shared with')).toBeNull();
36
+ });
37
+
23
38
  test('a person known only by email is named by the address, once — not "Someone" above it', () => {
24
39
  const { getAllByText, queryByText } = render(
25
40
  <ShareEditor
@@ -35,6 +35,10 @@ export interface ShareEditorProps {
35
35
  /** Revoke a grant (a confirm step fires first). */
36
36
  onRevoke?: (grantId: string) => void;
37
37
  emptyMessage?: string;
38
+ /** The picker's placeholder — name what it adds ("Add a group…"). Default `'Add people or groups…'`. */
39
+ placeholder?: string;
40
+ /** The heading over the current grants. Default `'Shared with'`. */
41
+ listLabel?: string;
38
42
  className?: string;
39
43
  }
40
44
 
@@ -152,6 +156,8 @@ export function ShareEditor({
152
156
  onChangeLevel,
153
157
  onRevoke,
154
158
  emptyMessage = 'Not shared with anyone yet.',
159
+ placeholder,
160
+ listLabel = 'Shared with',
155
161
  className,
156
162
  // Named so it stays off the root `<div>`: the handlers already carry the resource.
157
163
  resource: _resource,
@@ -166,10 +172,15 @@ export function ShareEditor({
166
172
 
167
173
  return (
168
174
  <div {...rest} className={cn('space-y-4', className)}>
169
- <RecipientPicker loadRecipients={loadRecipients} onPick={onAdd} excludeIds={excludeIds} />
175
+ <RecipientPicker
176
+ loadRecipients={loadRecipients}
177
+ onPick={onAdd}
178
+ excludeIds={excludeIds}
179
+ {...(placeholder ? { placeholder } : {})}
180
+ />
170
181
 
171
182
  <div>
172
- <p className='mb-1 text-xs font-semibold text-muted-foreground'>Shared with</p>
183
+ <p className='mb-1 text-xs font-semibold text-muted-foreground'>{listLabel}</p>
173
184
  {isLoading ? (
174
185
  <div className='flex justify-center py-4'>
175
186
  <Spinner />
@@ -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
  },
@@ -603,6 +603,13 @@ export const IconCode = handDrawnIcon(() => <><path d="m9 8-5 4 5 4" /><path d="
603
603
  */
604
604
  export const IconSplitRow = handDrawnIcon(() => <><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M12 4v16" /></>);
605
605
  export const IconSplitColumn = handDrawnIcon(() => <><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M3 12h18" /></>);
606
+ /**
607
+ * A spreadsheet's Merge Cells and Unmerge Cells (forge task 3569), Lucide's `table-cells-merge`
608
+ * and `table-cells-split`: a three-row table whose middle row has lost its divider, and one whose
609
+ * middle row has it back.
610
+ */
611
+ export const IconMergeCells = handDrawnIcon(() => <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /><path d="M12 3v6" /><path d="M12 15v6" /></>);
612
+ export const IconUnmergeCells = handDrawnIcon(() => <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /><path d="M12 9v6" /></>);
606
613
  /** A pane closing: the frame, with the corner taken out of it. */
607
614
  export const IconPaneClose = handDrawnIcon(() => <><rect x="3" y="4" width="18" height="16" rx="2" /><path d="m9.5 10.5 5 5" /><path d="m14.5 10.5-5 5" /></>);
608
615
  /**
@@ -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';