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.
- 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/DataTable/DataTable.d.ts +14 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +18 -7
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/Drawer.d.ts +8 -1
- package/dist/react/components/Drawer.d.ts.map +1 -1
- package/dist/react/components/Drawer.js +2 -2
- package/dist/react/components/Drawer.js.map +1 -1
- package/dist/react/components/Page.d.ts +9 -1
- package/dist/react/components/Page.d.ts.map +1 -1
- package/dist/react/components/Page.js +3 -2
- package/dist/react/components/Page.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/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +9 -3
- package/dist/react/file-tree/FileTree.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/nav/RailPlaces.d.ts +6 -2
- package/dist/react/nav/RailPlaces.d.ts.map +1 -1
- package/dist/react/nav/RailPlaces.js +1 -1
- package/dist/react/nav/RailPlaces.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/dist/styles-areas/analytics.css +5 -5
- package/dist/styles-areas/data-table.css +5 -5
- package/dist/styles-utilities.css +10 -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/DataTable/DataTable.tsx +37 -7
- package/src/react/components/DataTable/columnHeaders.spec.tsx +83 -0
- package/src/react/components/Drawer.tsx +13 -0
- package/src/react/components/Modal.spec.tsx +14 -0
- package/src/react/components/Page.spec.tsx +20 -0
- package/src/react/components/Page.tsx +35 -14
- package/src/react/createApiHooks.spec.tsx +77 -0
- package/src/react/createApiHooks.ts +58 -1
- package/src/react/file-tree/FileTree.spec.tsx +14 -0
- package/src/react/file-tree/FileTree.tsx +9 -3
- package/src/react/index.ts +2 -0
- package/src/react/nav/RailPlaces.spec.tsx +6 -0
- package/src/react/nav/RailPlaces.tsx +12 -2
- 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
- package/src/styles-areas/analytics.css +5 -5
- package/src/styles-areas/data-table.css +5 -5
- 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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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");
|
package/src/styles-utilities.css
CHANGED
|
@@ -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
|
}
|