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.
- package/dist/react/LazyToaster.d.ts +3 -0
- package/dist/react/LazyToaster.d.ts.map +1 -0
- package/dist/react/LazyToaster.js +21 -0
- package/dist/react/LazyToaster.js.map +1 -0
- package/dist/react/components/sharing/ShareEditor.d.ts +5 -1
- package/dist/react/components/sharing/ShareEditor.d.ts.map +1 -1
- package/dist/react/components/sharing/ShareEditor.js +2 -2
- package/dist/react/components/sharing/ShareEditor.js.map +1 -1
- package/dist/react/createApiHooks.d.ts +30 -0
- package/dist/react/createApiHooks.d.ts.map +1 -1
- package/dist/react/createApiHooks.js +31 -2
- package/dist/react/createApiHooks.js.map +1 -1
- package/dist/react/icons.d.ts +7 -0
- package/dist/react/icons.d.ts.map +1 -1
- package/dist/react/icons.js +7 -0
- package/dist/react/icons.js.map +1 -1
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/optimistic/index.d.ts +10 -0
- package/dist/react/optimistic/index.d.ts.map +1 -0
- package/dist/react/optimistic/index.js +22 -0
- package/dist/react/optimistic/index.js.map +1 -0
- package/dist/react/optimistic/optimistic.d.ts +159 -0
- package/dist/react/optimistic/optimistic.d.ts.map +1 -0
- package/dist/react/optimistic/optimistic.js +216 -0
- package/dist/react/optimistic/optimistic.js.map +1 -0
- package/dist/react/optimistic/revert.d.ts +47 -0
- package/dist/react/optimistic/revert.d.ts.map +1 -0
- package/dist/react/optimistic/revert.js +235 -0
- package/dist/react/optimistic/revert.js.map +1 -0
- package/package.json +1 -1
- package/scripts/styleAreas.ts +3 -0
- package/src/react/LazyToaster.spec.tsx +18 -0
- package/src/react/LazyToaster.tsx +26 -0
- package/src/react/components/sharing/ShareEditor.spec.tsx +15 -0
- package/src/react/components/sharing/ShareEditor.tsx +13 -2
- package/src/react/createApiHooks.spec.tsx +77 -0
- package/src/react/createApiHooks.ts +58 -1
- package/src/react/icons.tsx +7 -0
- package/src/react/index.ts +2 -0
- package/src/react/media/playerStrip.spec.tsx +8 -4
- package/src/react/optimistic/index.ts +36 -0
- package/src/react/optimistic/optimistic.spec.tsx +225 -0
- package/src/react/optimistic/optimistic.ts +326 -0
- package/src/react/optimistic/queryTarget.spec.ts +64 -0
- package/src/react/optimistic/revert.spec.ts +114 -0
- package/src/react/optimistic/revert.ts +238 -0
- 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.
|
|
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.",
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -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
|
|
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'>
|
|
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:
|
|
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
|
},
|
package/src/react/icons.tsx
CHANGED
|
@@ -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
|
/**
|
package/src/react/index.ts
CHANGED
|
@@ -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';
|