cursedbelt 5.89.0 → 5.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/react/LazyToaster.d.ts +3 -0
  2. package/dist/react/LazyToaster.d.ts.map +1 -0
  3. package/dist/react/LazyToaster.js +21 -0
  4. package/dist/react/LazyToaster.js.map +1 -0
  5. package/dist/react/createApiHooks.d.ts +30 -0
  6. package/dist/react/createApiHooks.d.ts.map +1 -1
  7. package/dist/react/createApiHooks.js +31 -2
  8. package/dist/react/createApiHooks.js.map +1 -1
  9. package/dist/react/index.d.ts +2 -0
  10. package/dist/react/index.d.ts.map +1 -1
  11. package/dist/react/index.js +2 -0
  12. package/dist/react/index.js.map +1 -1
  13. package/dist/react/optimistic/index.d.ts +10 -0
  14. package/dist/react/optimistic/index.d.ts.map +1 -0
  15. package/dist/react/optimistic/index.js +22 -0
  16. package/dist/react/optimistic/index.js.map +1 -0
  17. package/dist/react/optimistic/optimistic.d.ts +159 -0
  18. package/dist/react/optimistic/optimistic.d.ts.map +1 -0
  19. package/dist/react/optimistic/optimistic.js +216 -0
  20. package/dist/react/optimistic/optimistic.js.map +1 -0
  21. package/dist/react/optimistic/revert.d.ts +47 -0
  22. package/dist/react/optimistic/revert.d.ts.map +1 -0
  23. package/dist/react/optimistic/revert.js +235 -0
  24. package/dist/react/optimistic/revert.js.map +1 -0
  25. package/package.json +1 -1
  26. package/scripts/styleAreas.ts +3 -0
  27. package/src/react/LazyToaster.spec.tsx +18 -0
  28. package/src/react/LazyToaster.tsx +26 -0
  29. package/src/react/createApiHooks.spec.tsx +77 -0
  30. package/src/react/createApiHooks.ts +58 -1
  31. package/src/react/index.ts +2 -0
  32. package/src/react/optimistic/index.ts +36 -0
  33. package/src/react/optimistic/optimistic.spec.tsx +225 -0
  34. package/src/react/optimistic/optimistic.ts +326 -0
  35. package/src/react/optimistic/queryTarget.spec.ts +64 -0
  36. package/src/react/optimistic/revert.spec.ts +114 -0
  37. package/src/react/optimistic/revert.ts +238 -0
  38. package/src/react/optimistic/staysOffTheEntry.spec.ts +38 -0
@@ -0,0 +1,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
+ });