cursedbelt 5.89.0 → 5.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/react/LazyToaster.d.ts +3 -0
  2. package/dist/react/LazyToaster.d.ts.map +1 -0
  3. package/dist/react/LazyToaster.js +21 -0
  4. package/dist/react/LazyToaster.js.map +1 -0
  5. package/dist/react/components/DataTable/DataTable.d.ts +14 -1
  6. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  7. package/dist/react/components/DataTable/DataTable.js +18 -7
  8. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  9. package/dist/react/components/Drawer.d.ts +8 -1
  10. package/dist/react/components/Drawer.d.ts.map +1 -1
  11. package/dist/react/components/Drawer.js +2 -2
  12. package/dist/react/components/Drawer.js.map +1 -1
  13. package/dist/react/components/Page.d.ts +9 -1
  14. package/dist/react/components/Page.d.ts.map +1 -1
  15. package/dist/react/components/Page.js +3 -2
  16. package/dist/react/components/Page.js.map +1 -1
  17. package/dist/react/createApiHooks.d.ts +30 -0
  18. package/dist/react/createApiHooks.d.ts.map +1 -1
  19. package/dist/react/createApiHooks.js +31 -2
  20. package/dist/react/createApiHooks.js.map +1 -1
  21. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  22. package/dist/react/file-tree/FileTree.js +9 -3
  23. package/dist/react/file-tree/FileTree.js.map +1 -1
  24. package/dist/react/index.d.ts +2 -0
  25. package/dist/react/index.d.ts.map +1 -1
  26. package/dist/react/index.js +2 -0
  27. package/dist/react/index.js.map +1 -1
  28. package/dist/react/nav/RailPlaces.d.ts +6 -2
  29. package/dist/react/nav/RailPlaces.d.ts.map +1 -1
  30. package/dist/react/nav/RailPlaces.js +1 -1
  31. package/dist/react/nav/RailPlaces.js.map +1 -1
  32. package/dist/react/optimistic/index.d.ts +10 -0
  33. package/dist/react/optimistic/index.d.ts.map +1 -0
  34. package/dist/react/optimistic/index.js +22 -0
  35. package/dist/react/optimistic/index.js.map +1 -0
  36. package/dist/react/optimistic/optimistic.d.ts +159 -0
  37. package/dist/react/optimistic/optimistic.d.ts.map +1 -0
  38. package/dist/react/optimistic/optimistic.js +216 -0
  39. package/dist/react/optimistic/optimistic.js.map +1 -0
  40. package/dist/react/optimistic/revert.d.ts +47 -0
  41. package/dist/react/optimistic/revert.d.ts.map +1 -0
  42. package/dist/react/optimistic/revert.js +235 -0
  43. package/dist/react/optimistic/revert.js.map +1 -0
  44. package/dist/styles-areas/analytics.css +5 -5
  45. package/dist/styles-areas/data-table.css +5 -5
  46. package/dist/styles-utilities.css +10 -0
  47. package/package.json +1 -1
  48. package/scripts/styleAreas.ts +3 -0
  49. package/src/react/LazyToaster.spec.tsx +18 -0
  50. package/src/react/LazyToaster.tsx +26 -0
  51. package/src/react/components/DataTable/DataTable.tsx +37 -7
  52. package/src/react/components/DataTable/columnHeaders.spec.tsx +83 -0
  53. package/src/react/components/Drawer.tsx +13 -0
  54. package/src/react/components/Modal.spec.tsx +14 -0
  55. package/src/react/components/Page.spec.tsx +20 -0
  56. package/src/react/components/Page.tsx +35 -14
  57. package/src/react/createApiHooks.spec.tsx +77 -0
  58. package/src/react/createApiHooks.ts +58 -1
  59. package/src/react/file-tree/FileTree.spec.tsx +14 -0
  60. package/src/react/file-tree/FileTree.tsx +9 -3
  61. package/src/react/index.ts +2 -0
  62. package/src/react/nav/RailPlaces.spec.tsx +6 -0
  63. package/src/react/nav/RailPlaces.tsx +12 -2
  64. package/src/react/optimistic/index.ts +36 -0
  65. package/src/react/optimistic/optimistic.spec.tsx +225 -0
  66. package/src/react/optimistic/optimistic.ts +326 -0
  67. package/src/react/optimistic/queryTarget.spec.ts +64 -0
  68. package/src/react/optimistic/revert.spec.ts +114 -0
  69. package/src/react/optimistic/revert.ts +238 -0
  70. package/src/react/optimistic/staysOffTheEntry.spec.ts +38 -0
  71. package/src/styles-areas/analytics.css +5 -5
  72. package/src/styles-areas/data-table.css +5 -5
  73. package/src/styles-utilities.css +10 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `queryTarget` refetches a cache entry once, after the LAST optimistic change on it settles.
3
+ * Refetching after each one drew the second of two quick deletes back for a moment: the first
4
+ * answer's refetch read the server before the second delete had landed (collections and desk both
5
+ * met it, forge task 3583). Its own file so it imports only what predates the fix.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, type Mock, spyOn, test } from 'bun:test';
8
+ import { toast } from '../Toast.js';
9
+ import { optimistic, queryTarget } from './optimistic.js';
10
+
11
+ type Row = { id: string; title: string };
12
+ const rows: Row[] = [
13
+ { id: 'a', title: 'A' },
14
+ { id: 'b', title: 'B' },
15
+ { id: 'c', title: 'C' },
16
+ ];
17
+
18
+ const deferred = <T,>() => {
19
+ let resolve!: (value: T) => void;
20
+ const promise = new Promise<T>((res) => {
21
+ resolve = res;
22
+ });
23
+ return { promise, resolve };
24
+ };
25
+
26
+ let toastError: Mock<typeof toast.error>;
27
+ beforeEach(() => {
28
+ toastError = spyOn(toast, 'error').mockImplementation(() => 0);
29
+ });
30
+ afterEach(() => toastError.mockRestore());
31
+
32
+ describe('queryTarget', () => {
33
+ test('two changes on one cache entry refetch it ONCE, after the last settles', async () => {
34
+ const calls: string[] = [];
35
+ let data: unknown = rows;
36
+ const client = {
37
+ cancelQueries: () => undefined,
38
+ setQueryData: (_k: readonly unknown[], updater: (old: unknown) => unknown) => {
39
+ data = updater(data);
40
+ },
41
+ invalidateQueries: () => void calls.push('invalidate'),
42
+ };
43
+ const first = deferred<void>();
44
+ const second = deferred<void>();
45
+ const one = optimistic({
46
+ label: 'delete “A”',
47
+ targets: { ...queryTarget<Row[]>(client, ['rows']), apply: (r: Row[]) => r.filter((x) => x.id !== 'a') },
48
+ request: () => first.promise,
49
+ });
50
+ const two = optimistic({
51
+ label: 'delete “B”',
52
+ targets: { ...queryTarget<Row[]>(client, ['rows']), apply: (r: Row[]) => r.filter((x) => x.id !== 'b') },
53
+ request: () => second.promise,
54
+ });
55
+ first.resolve();
56
+ await one;
57
+ expect(calls).toEqual([]); // B is still in flight: a refetch now would draw it back
58
+ second.resolve();
59
+ await two;
60
+ expect(calls).toEqual(['invalidate']);
61
+ expect((data as Row[]).map((r) => r.id)).toEqual(['c']);
62
+ });
63
+
64
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { counterRevert, inverseOf } from './revert.js';
3
+
4
+ type Row = { id: string; title: string; liked?: boolean };
5
+ const rows: Row[] = [
6
+ { id: 'a', title: 'A' },
7
+ { id: 'b', title: 'B' },
8
+ { id: 'c', title: 'C' },
9
+ ];
10
+ const without = (list: Row[], id: string) => list.filter((r) => r.id !== id);
11
+ const rename = (list: Row[], id: string, title: string) => list.map((r) => (r.id === id ? { ...r, title } : r));
12
+
13
+ describe('inverseOf — arrays of records', () => {
14
+ test('a removed row comes back where it was', () => {
15
+ const after = without(rows, 'b');
16
+ expect(inverseOf(rows, after)(after)).toEqual(rows);
17
+ });
18
+
19
+ test('two deletes, the FIRST fails: only that row returns, the second stays gone', () => {
20
+ const afterA = without(rows, 'a');
21
+ const afterB = without(afterA, 'b');
22
+ expect(inverseOf(rows, afterA)(afterB).map((r) => r.id)).toEqual(['a', 'c']);
23
+ });
24
+
25
+ test('a removed row returns beside its surviving neighbor when rows before it went too', () => {
26
+ const afterB = without(rows, 'b');
27
+ const now = without(afterB, 'a'); // another change took A since
28
+ expect(inverseOf(rows, afterB)(now).map((r) => r.id)).toEqual(['b', 'c']);
29
+ });
30
+
31
+ test('a row the server or a refetch already put back is not doubled', () => {
32
+ const after = without(rows, 'b');
33
+ expect(inverseOf(rows, after)(rows)).toBe(rows);
34
+ });
35
+
36
+ test('an added row is taken out; rows added since stay', () => {
37
+ const after = [...rows, { id: 'tmp', title: 'New' }];
38
+ const now = [...after, { id: 'd', title: 'D' }];
39
+ expect(inverseOf(rows, after)(now).map((r) => r.id)).toEqual(['a', 'b', 'c', 'd']);
40
+ });
41
+
42
+ test('a patch reverts only the fields it set, and keeps a later edit to another field', () => {
43
+ const after = rows.map((r) => (r.id === 'b' ? { ...r, liked: true } : r));
44
+ const now = rename(after, 'b', 'Renamed since');
45
+ const reverted = inverseOf(rows, after)(now);
46
+ expect(reverted[1]).toEqual({ id: 'b', title: 'Renamed since' });
47
+ });
48
+
49
+ test('rename twice, the first fails: the second title stands', () => {
50
+ const first = rename(rows, 'b', 'X');
51
+ const second = rename(first, 'b', 'Y');
52
+ expect(inverseOf(rows, first)(second)[1]?.title).toBe('Y');
53
+ });
54
+
55
+ test('a reorder is undone only while the optimistic order still stands', () => {
56
+ const after = [rows[2], rows[0], rows[1]] as Row[];
57
+ expect(inverseOf(rows, after)(after).map((r) => r.id)).toEqual(['a', 'b', 'c']);
58
+ const movedAgain = [rows[0], rows[2], rows[1]] as Row[];
59
+ expect(inverseOf(rows, after)(movedAgain)).toBe(movedAgain);
60
+ });
61
+
62
+ test('nothing to revert returns the same reference, so a setter does not re-render', () => {
63
+ const after = without(rows, 'b');
64
+ const unrelated = [{ id: 'z', title: 'Z' }];
65
+ // `b` is not there, but neither is its neighbor; it still comes back, at the top.
66
+ expect(inverseOf(rows, after)(unrelated).map((r) => r.id)).toEqual(['b', 'z']);
67
+ expect(inverseOf(rows, rows)(unrelated)).toBe(unrelated);
68
+ });
69
+
70
+ test('a custom idOf names rows that carry no id', () => {
71
+ const files = [{ path: '/a' }, { path: '/b' }];
72
+ const after = files.slice(1);
73
+ const idOf = (f: unknown) => (f as { path: string }).path;
74
+ expect(inverseOf(files, after, idOf)(after)).toEqual(files);
75
+ });
76
+ });
77
+
78
+ describe('inverseOf — other shapes', () => {
79
+ test('primitive arrays behave as sets of values (a tag list)', () => {
80
+ const tags = ['x', 'y'];
81
+ const after = ['x', 'y', 'z'];
82
+ expect(inverseOf(tags, after)(['x', 'y', 'z', 'w'])).toEqual(['x', 'y', 'w']);
83
+ });
84
+
85
+ test('objects recurse key by key: a page { items, total }', () => {
86
+ const before = { items: rows, total: 3 };
87
+ const after = { items: without(rows, 'a'), total: 2 };
88
+ expect(inverseOf(before, after)(after)).toEqual(before);
89
+ });
90
+
91
+ test('a key the change added goes; one it removed comes back', () => {
92
+ const before: Record<string, number> = { a: 1, b: 2 };
93
+ const after: Record<string, number> = { a: 1, c: 3 };
94
+ expect(inverseOf(before, after)(after)).toEqual({ a: 1, b: 2 });
95
+ });
96
+
97
+ test('Set and Map members', () => {
98
+ const before = new Set(['a']);
99
+ const after = new Set(['a', 'b']);
100
+ expect([...inverseOf(before, after)(new Set(['a', 'b', 'c']))]).toEqual(['a', 'c']);
101
+ const mBefore = new Map([['a', { n: 1 }]]);
102
+ const mAfter = new Map<string, { n: number }>();
103
+ expect(inverseOf(mBefore, mAfter)(new Map()).get('a')).toEqual({ n: 1 });
104
+ });
105
+
106
+ test('a scalar returns only if it still holds the optimistic value', () => {
107
+ expect(inverseOf(false, true)(true)).toBe(false);
108
+ expect(inverseOf(1, 2)(3)).toBe(3);
109
+ });
110
+
111
+ test('counterRevert gives back the difference, whatever happened since', () => {
112
+ expect(counterRevert(8, 10, 9)).toBe(9);
113
+ });
114
+ });
@@ -0,0 +1,238 @@
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
+
33
+ /** How an array item is identified. Records by `id` (or `key`), primitives by value. */
34
+ export type IdOf = (item: unknown) => unknown;
35
+
36
+ const NO_ID = Symbol('no-id');
37
+
38
+ export const defaultIdOf: IdOf = (item) => {
39
+ if (item === null || typeof item !== 'object') return item;
40
+ const record = item as { id?: unknown; key?: unknown };
41
+ if (record.id !== undefined) return record.id;
42
+ if (record.key !== undefined) return record.key;
43
+ return NO_ID;
44
+ };
45
+
46
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
47
+ if (value === null || typeof value !== 'object') return false;
48
+ const proto = Object.getPrototypeOf(value);
49
+ return proto === Object.prototype || proto === null;
50
+ };
51
+
52
+ /**
53
+ * The inverse of the change `before → after`, as a function of the state at revert time.
54
+ * `idOf` names array items; the default reads `id`, then `key`, and uses a primitive's value.
55
+ */
56
+ export function inverseOf<S>(before: S, after: S, idOf: IdOf = defaultIdOf): (current: S) => S {
57
+ return (current) => revertValue(before, after, current, idOf) as S;
58
+ }
59
+
60
+ function revertValue(before: unknown, after: unknown, current: unknown, idOf: IdOf): unknown {
61
+ if (Object.is(before, after)) return current;
62
+ if (Array.isArray(before) && Array.isArray(after) && Array.isArray(current)) {
63
+ const reverted = revertArray(before, after, current, idOf);
64
+ if (reverted !== undefined) return reverted;
65
+ }
66
+ if (before instanceof Set && after instanceof Set && current instanceof Set) {
67
+ return revertSet(before, after, current);
68
+ }
69
+ if (before instanceof Map && after instanceof Map && current instanceof Map) {
70
+ return revertMap(before, after, current, idOf);
71
+ }
72
+ if (isPlainObject(before) && isPlainObject(after) && isPlainObject(current)) {
73
+ return revertObject(before, after, current, idOf);
74
+ }
75
+ return Object.is(current, after) ? before : current;
76
+ }
77
+
78
+ function revertObject(
79
+ before: Record<string, unknown>,
80
+ after: Record<string, unknown>,
81
+ current: Record<string, unknown>,
82
+ idOf: IdOf,
83
+ ): Record<string, unknown> {
84
+ let out: Record<string, unknown> | undefined;
85
+ const write = (key: string, value: unknown, remove = false) => {
86
+ out ??= { ...current };
87
+ if (remove) delete out[key];
88
+ else out[key] = value;
89
+ };
90
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
91
+ const had = Object.hasOwn(before, key);
92
+ const has = Object.hasOwn(after, key);
93
+ if (had && has) {
94
+ if (Object.is(before[key], after[key])) continue;
95
+ if (!Object.hasOwn(current, key)) continue; // removed since — not ours to bring back
96
+ const next = revertValue(before[key], after[key], current[key], idOf);
97
+ if (!Object.is(next, current[key])) write(key, next);
98
+ } else if (has) {
99
+ // Added by the change: take it out if it is still what the change put there.
100
+ if (Object.hasOwn(current, key) && Object.is(current[key], after[key])) write(key, undefined, true);
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
+
108
+ /** `undefined` when the items cannot be identified — the caller falls back to whole-value. */
109
+ function revertArray(before: unknown[], after: unknown[], current: unknown[], idOf: IdOf): unknown[] | undefined {
110
+ const ids = (list: unknown[]) => list.map(idOf);
111
+ const beforeIds = ids(before);
112
+ const afterIds = ids(after);
113
+ const unique = (list: unknown[]) => !list.includes(NO_ID) && new Set(list).size === list.length;
114
+ if (!unique(beforeIds) || !unique(afterIds)) return undefined;
115
+
116
+ const beforeById = new Map(beforeIds.map((id, i) => [id, before[i]]));
117
+ const afterById = new Map(afterIds.map((id, i) => [id, after[i]]));
118
+ let out = current;
119
+ let touched = false;
120
+ const edit = () => {
121
+ if (!touched) {
122
+ out = [...current];
123
+ touched = true;
124
+ }
125
+ };
126
+
127
+ // 1 · Items the change ADDED go, if they are still there.
128
+ const added = afterIds.filter((id) => !beforeById.has(id));
129
+ if (added.length > 0) {
130
+ const drop = new Set(added);
131
+ const kept = out.filter((item) => !drop.has(idOf(item)));
132
+ if (kept.length !== out.length) {
133
+ out = kept;
134
+ touched = true;
135
+ }
136
+ }
137
+
138
+ // 2 · Items the change CHANGED revert field by field.
139
+ for (const [id, was] of beforeById) {
140
+ const now = afterById.get(id);
141
+ if (now === undefined || Object.is(was, now)) continue;
142
+ const at = out.findIndex((item) => Object.is(idOf(item), id));
143
+ if (at < 0) continue;
144
+ const next = revertValue(was, now, out[at], idOf);
145
+ if (!Object.is(next, out[at])) {
146
+ edit();
147
+ out[at] = next;
148
+ }
149
+ }
150
+
151
+ // 3 · Items the change REMOVED come back beside the neighbor they had.
152
+ const removed = beforeIds.flatMap((id, index) => (afterById.has(id) ? [] : [{ id, index }]));
153
+ for (const { id, index } of removed) {
154
+ if (out.some((item) => Object.is(idOf(item), id))) continue;
155
+ edit();
156
+ let at = 0;
157
+ for (let i = index - 1; i >= 0; i--) {
158
+ const neighbor = out.findIndex((item) => Object.is(idOf(item), beforeIds[i]));
159
+ if (neighbor >= 0) {
160
+ at = neighbor + 1;
161
+ break;
162
+ }
163
+ }
164
+ out.splice(at, 0, beforeById.get(id));
165
+ }
166
+
167
+ // 4 · A REORDER: the old order back, only where the optimistic order still stands.
168
+ const kept = beforeIds.filter((id) => afterById.has(id));
169
+ const keptAfter = afterIds.filter((id) => beforeById.has(id));
170
+ if (kept.some((id, i) => !Object.is(id, keptAfter[i]))) {
171
+ const keep = new Set(kept);
172
+ const positions: number[] = [];
173
+ out.forEach((item, i) => {
174
+ if (keep.has(idOf(item))) positions.push(i);
175
+ });
176
+ const nowOrder = positions.map((i) => idOf(out[i]));
177
+ if (nowOrder.length === keptAfter.length && nowOrder.every((id, i) => Object.is(id, keptAfter[i]))) {
178
+ const byId = new Map(positions.map((i) => [idOf(out[i]), out[i]]));
179
+ edit();
180
+ positions.forEach((slot, i) => {
181
+ out[slot] = byId.get(kept[i]);
182
+ });
183
+ }
184
+ }
185
+
186
+ return touched ? out : current;
187
+ }
188
+
189
+ function revertSet(before: Set<unknown>, after: Set<unknown>, current: Set<unknown>): Set<unknown> {
190
+ let out: Set<unknown> | undefined;
191
+ const copy = () => {
192
+ out ??= new Set(current);
193
+ return out;
194
+ };
195
+ for (const member of after) {
196
+ if (!before.has(member) && current.has(member)) copy().delete(member);
197
+ }
198
+ for (const member of before) {
199
+ if (!after.has(member) && !current.has(member)) copy().add(member);
200
+ }
201
+ return out ?? current;
202
+ }
203
+
204
+ function revertMap(
205
+ before: Map<unknown, unknown>,
206
+ after: Map<unknown, unknown>,
207
+ current: Map<unknown, unknown>,
208
+ idOf: IdOf,
209
+ ): Map<unknown, unknown> {
210
+ let out: Map<unknown, unknown> | undefined;
211
+ const copy = () => {
212
+ out ??= new Map(current);
213
+ return out;
214
+ };
215
+ for (const key of new Set([...before.keys(), ...after.keys()])) {
216
+ const had = before.has(key);
217
+ const has = after.has(key);
218
+ if (had && has) {
219
+ if (Object.is(before.get(key), after.get(key)) || !current.has(key)) continue;
220
+ const next = revertValue(before.get(key), after.get(key), current.get(key), idOf);
221
+ if (!Object.is(next, current.get(key))) copy().set(key, next);
222
+ } else if (has) {
223
+ if (current.has(key) && Object.is(current.get(key), after.get(key))) copy().delete(key);
224
+ } else if (!current.has(key)) {
225
+ copy().set(key, before.get(key));
226
+ }
227
+ }
228
+ return out ?? current;
229
+ }
230
+
231
+ /**
232
+ * A counter's inverse: give back the difference the change made, whatever has happened since.
233
+ * The whole-value rule is wrong for a count — two deletes take a total 10 → 9 → 8, and when the
234
+ * first fails the total is 8, not 9, so "revert only if still optimistic" would never fire. Pass
235
+ * it as a target's `revert`: `{ set: setTotal, apply: (n) => n - 1, revert: counterRevert }`.
236
+ */
237
+ export const counterRevert = (current: number, before: number, after: number): number =>
238
+ current + (before - after);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 🔴 `optimistic()` is on every app's busiest paths, so whatever it imports STATICALLY lands on
3
+ * their entry chunks. Its first version imported `../Toast.js` at the top, which put sonner
4
+ * (~10 kB gzip) back on flix's and roms' entries — the bytes cursedbelt 5.64 took off them
5
+ * (forge task 2900) — and both apps had to raise their asset budgets (forge task 3583).
6
+ *
7
+ * The toast is loaded at the first refusal instead. This fails if any file of the module reaches
8
+ * the toast or sonner without `import()`.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { readdirSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ const dir = import.meta.dir;
15
+ // `LazyToaster` is the other half: the stack an app mounts must not pull sonner in statically either.
16
+ const sources = [
17
+ ...readdirSync(dir)
18
+ .filter((f) => /\.tsx?$/.test(f) && !/\.spec\.tsx?$/.test(f))
19
+ .map((f) => join(dir, f)),
20
+ join(dir, '..', 'LazyToaster.tsx'),
21
+ ];
22
+ const STATIC = /^\s*import\s+(?!type\b)[^;]*?from\s+['"]([^'"]+)['"]/gm;
23
+
24
+ describe('the optimistic module stays off the entry chunk', () => {
25
+ test('no file imports the toast or sonner statically', () => {
26
+ const offenders = sources.flatMap((file) =>
27
+ [...readFileSync(file, 'utf8').matchAll(STATIC)]
28
+ .map((m) => m[1] ?? '')
29
+ .filter((spec) => spec === 'sonner' || /Toast(\.js)?$/.test(spec))
30
+ .map((spec) => `${file} → ${spec}`),
31
+ );
32
+ expect(offenders).toEqual([]);
33
+ });
34
+
35
+ test('the check sees a static import when there is one', () => {
36
+ expect([..."import { toast } from '../Toast.js';".matchAll(STATIC)].map((m) => m[1])).toEqual(['../Toast.js']);
37
+ });
38
+ });
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 156 candidate(s).
21
+ * 159 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -33,19 +33,19 @@
33
33
  @source inline("@max-6xl:text-[length:0] @max-6xl:w-0! @max-xl:contents @xl:basis-auto @xl:block @xl:hidden");
34
34
  @source inline("[&>*]:hidden [&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 active:cursor-grabbing basis");
35
35
  @source inline("basis-full bg-[var(--cb-frozen-bg)] bg-accent/15 bg-card/95 bg-gradient-to-t bg-muted/30");
36
- @source inline("bg-muted/50 border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col");
37
- @source inline("collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
36
+ @source inline("bg-muted/50 border-accent-solid border-accent/60 border-b-0! border-field-error border-solid");
37
+ @source inline("border-t-2 col collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
38
38
  @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
39
39
  @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
40
40
  @source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
41
- @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-105");
41
+ @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-0 h-105");
42
42
  @source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-muted/40");
43
43
  @source inline("hover:border-field-ring hover:text-warning italic justify-self-end last:border-0 left-11");
44
44
  @source inline("left-2.5 list-none max-h-64 max-h-88 max-sm:flex-1 max-sm:flex-wrap max-sm:min-w-0");
45
45
  @source inline("max-sm:min-w-24 max-sm:min-w-32 max-sm:ml-13.5 max-sm:w-auto max-w-32 max-w-36 max-w-45 max-w-48");
46
46
  @source inline("max-w-60 min-h-20 min-h-dvh min-w-0! min-w-20 min-w-24 min-w-4 min-w-52");
47
47
  @source inline("min-w-[var(--anchor-width)] min-w-max mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5");
48
- @source inline("pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9 pt-1 resize-none resize-y right-3");
48
+ @source inline("pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9 pt-1 py-0! resize-none resize-y right-3");
49
49
  @source inline("ring-field-error/30 self self-stretch shadow-[1px_0_0_0_var(--color-border)] shrink-[2] size-7");
50
50
  @source inline("slide-in-from-top-1 table-fixed text-[length:0] text-accent-solid-foreground");
51
51
  @source inline("text-field-placeholder text-success text-xl to-transparent top-3 transition-[transform,opacity]");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 151 candidate(s).
21
+ * 154 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -33,19 +33,19 @@
33
33
  @source inline("@max-6xl:text-[length:0] @max-6xl:w-0! @max-xl:contents @xl:basis-auto @xl:block @xl:hidden");
34
34
  @source inline("[&>*]:hidden [&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 active:cursor-grabbing basis-full");
35
35
  @source inline("bg-[var(--cb-frozen-bg)] bg-accent/15 bg-card/95 bg-gradient-to-t bg-muted/30 bg-muted/50");
36
- @source inline("border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col collapse");
37
- @source inline("cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
36
+ @source inline("border-accent-solid border-accent/60 border-b-0! border-field-error border-solid border-t-2 col");
37
+ @source inline("collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
38
38
  @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
39
39
  @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
40
40
  @source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
41
- @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-105");
41
+ @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-0 h-105");
42
42
  @source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-muted/40");
43
43
  @source inline("hover:border-field-ring hover:text-warning justify-self-end last:border-0 left-11 left-2.5");
44
44
  @source inline("list-none max-h-64 max-h-88 max-sm:flex-1 max-sm:flex-wrap max-sm:min-w-0 max-sm:min-w-24");
45
45
  @source inline("max-sm:min-w-32 max-sm:ml-13.5 max-sm:w-auto max-w-32 max-w-36 max-w-45 max-w-48 max-w-60");
46
46
  @source inline("min-h-20 min-h-dvh min-w-0! min-w-24 min-w-4 min-w-52 min-w-[var(--anchor-width)] min-w-max mr-2");
47
47
  @source inline("mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9");
48
- @source inline("pt-1 resize-none resize-y right-3 ring-field-error/30 self self-stretch");
48
+ @source inline("pt-1 py-0! resize-none resize-y right-3 ring-field-error/30 self self-stretch");
49
49
  @source inline("shadow-[1px_0_0_0_var(--color-border)] size-7 slide-in-from-top-1 table-fixed text-[length:0]");
50
50
  @source inline("text-accent-solid-foreground text-field-placeholder text-success text-xl to-transparent top-3");
51
51
  @source inline("transition-[transform,opacity] w-0! w-11 w-12 w-20 w-32 w-36 w-40 w-88");
@@ -656,6 +656,9 @@
656
656
  width: 100%;
657
657
  height: 100%;
658
658
  }
659
+ .h-0 {
660
+ height: 0px;
661
+ }
659
662
  .h-0\.5 {
660
663
  height: calc(var(--spacing) * 0.5);
661
664
  }
@@ -1726,6 +1729,10 @@
1726
1729
  border-bottom-style: var(--tw-border-style);
1727
1730
  border-bottom-width: 0px;
1728
1731
  }
1732
+ .border-b-0\! {
1733
+ border-bottom-style: var(--tw-border-style) !important;
1734
+ border-bottom-width: 0px !important;
1735
+ }
1729
1736
  .border-b-2 {
1730
1737
  border-bottom-style: var(--tw-border-style);
1731
1738
  border-bottom-width: 2px;
@@ -2639,6 +2646,9 @@
2639
2646
  .py-0 {
2640
2647
  padding-block: 0px;
2641
2648
  }
2649
+ .py-0\! {
2650
+ padding-block: 0px !important;
2651
+ }
2642
2652
  .py-0\.5 {
2643
2653
  padding-block: calc(var(--spacing) * 0.5);
2644
2654
  }