@connextar/house 0.1.1 → 0.3.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/README.md +286 -28
- package/dist/changelog/changelog.d.ts +60 -0
- package/dist/changelog/changelog.d.ts.map +1 -0
- package/dist/changelog/changelog.js +61 -0
- package/dist/changelog/changelog.js.map +1 -0
- package/dist/changelog/index.d.ts +2 -0
- package/dist/changelog/index.d.ts.map +1 -0
- package/dist/changelog/index.js +2 -0
- package/dist/changelog/index.js.map +1 -0
- package/dist/editor/block-handle.d.ts +15 -0
- package/dist/editor/block-handle.d.ts.map +1 -0
- package/dist/editor/block-handle.js +106 -0
- package/dist/editor/block-handle.js.map +1 -0
- package/dist/editor/blocks.d.ts +35 -0
- package/dist/editor/blocks.d.ts.map +1 -0
- package/dist/editor/blocks.js +114 -0
- package/dist/editor/blocks.js.map +1 -0
- package/dist/editor/bubble-toolbar.d.ts +11 -0
- package/dist/editor/bubble-toolbar.d.ts.map +1 -0
- package/dist/editor/bubble-toolbar.js +87 -0
- package/dist/editor/bubble-toolbar.js.map +1 -0
- package/dist/editor/content-index.d.ts +2 -0
- package/dist/editor/content-index.d.ts.map +1 -0
- package/dist/editor/content-index.js +2 -0
- package/dist/editor/content-index.js.map +1 -0
- package/dist/editor/content.d.ts +30 -0
- package/dist/editor/content.d.ts.map +1 -0
- package/dist/editor/content.js +256 -0
- package/dist/editor/content.js.map +1 -0
- package/dist/editor/editor.d.ts +52 -0
- package/dist/editor/editor.d.ts.map +1 -0
- package/dist/editor/editor.js +160 -0
- package/dist/editor/editor.js.map +1 -0
- package/dist/editor/emoji-index.d.ts +2 -0
- package/dist/editor/emoji-index.d.ts.map +1 -0
- package/dist/editor/emoji-index.js +2 -0
- package/dist/editor/emoji-index.js.map +1 -0
- package/dist/editor/emoji-picker.d.ts +16 -0
- package/dist/editor/emoji-picker.d.ts.map +1 -0
- package/dist/editor/emoji-picker.js +37 -0
- package/dist/editor/emoji-picker.js.map +1 -0
- package/dist/editor/emoji.d.ts +18 -0
- package/dist/editor/emoji.d.ts.map +1 -0
- package/dist/editor/emoji.js +418 -0
- package/dist/editor/emoji.js.map +1 -0
- package/dist/editor/index.d.ts +5 -0
- package/dist/editor/index.d.ts.map +1 -0
- package/dist/editor/index.js +6 -0
- package/dist/editor/index.js.map +1 -0
- package/dist/editor/suggestion-popup.d.ts +17 -0
- package/dist/editor/suggestion-popup.d.ts.map +1 -0
- package/dist/editor/suggestion-popup.js +57 -0
- package/dist/editor/suggestion-popup.js.map +1 -0
- package/dist/editor/suggestions.d.ts +93 -0
- package/dist/editor/suggestions.d.ts.map +1 -0
- package/dist/editor/suggestions.js +179 -0
- package/dist/editor/suggestions.js.map +1 -0
- package/dist/editor/ui.d.ts +54 -0
- package/dist/editor/ui.d.ts.map +1 -0
- package/dist/editor/ui.js +58 -0
- package/dist/editor/ui.js.map +1 -0
- package/dist/house.css +333 -0
- package/dist/internal/cx.d.ts +3 -0
- package/dist/internal/cx.d.ts.map +1 -0
- package/dist/internal/cx.js +5 -0
- package/dist/internal/cx.js.map +1 -0
- package/dist/internal/next-link.d.ts +23 -0
- package/dist/internal/next-link.d.ts.map +1 -0
- package/dist/internal/next-link.js +3 -0
- package/dist/internal/next-link.js.map +1 -0
- package/dist/markdown/guide.d.ts +98 -0
- package/dist/markdown/guide.d.ts.map +1 -0
- package/dist/markdown/guide.js +328 -0
- package/dist/markdown/guide.js.map +1 -0
- package/dist/markdown/index.d.ts +4 -0
- package/dist/markdown/index.d.ts.map +1 -0
- package/dist/markdown/index.js +4 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/table-view.d.ts +28 -0
- package/dist/markdown/table-view.d.ts.map +1 -0
- package/dist/markdown/table-view.js +25 -0
- package/dist/markdown/table-view.js.map +1 -0
- package/dist/markdown/table.d.ts +35 -0
- package/dist/markdown/table.d.ts.map +1 -0
- package/dist/markdown/table.js +79 -0
- package/dist/markdown/table.js.map +1 -0
- package/dist/release/changelog.d.ts +72 -0
- package/dist/release/changelog.d.ts.map +1 -0
- package/dist/release/changelog.js +92 -0
- package/dist/release/changelog.js.map +1 -0
- package/dist/release/index.d.ts +3 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +3 -0
- package/dist/release/index.js.map +1 -0
- package/dist/release/version.d.ts +33 -0
- package/dist/release/version.d.ts.map +1 -0
- package/dist/release/version.js +57 -0
- package/dist/release/version.js.map +1 -0
- package/dist/trail/index.d.ts +3 -0
- package/dist/trail/index.d.ts.map +1 -0
- package/dist/trail/index.js +3 -0
- package/dist/trail/index.js.map +1 -0
- package/dist/trail/rules.d.ts +44 -0
- package/dist/trail/rules.d.ts.map +1 -0
- package/dist/trail/rules.js +74 -0
- package/dist/trail/rules.js.map +1 -0
- package/dist/trail/trail.d.ts +49 -0
- package/dist/trail/trail.d.ts.map +1 -0
- package/dist/trail/trail.js +188 -0
- package/dist/trail/trail.js.map +1 -0
- package/dist/ux/errors.d.ts +26 -0
- package/dist/ux/errors.d.ts.map +1 -0
- package/dist/ux/errors.js +33 -0
- package/dist/ux/errors.js.map +1 -0
- package/dist/ux/feedback.d.ts +23 -0
- package/dist/ux/feedback.d.ts.map +1 -0
- package/dist/ux/feedback.js +22 -0
- package/dist/ux/feedback.js.map +1 -0
- package/dist/ux/index.d.ts +20 -0
- package/dist/ux/index.d.ts.map +1 -0
- package/dist/ux/index.js +20 -0
- package/dist/ux/index.js.map +1 -0
- package/dist/ux/navigation-index.d.ts +10 -0
- package/dist/ux/navigation-index.d.ts.map +1 -0
- package/dist/ux/navigation-index.js +10 -0
- package/dist/ux/navigation-index.js.map +1 -0
- package/dist/ux/navigation-progress.d.ts +53 -0
- package/dist/ux/navigation-progress.d.ts.map +1 -0
- package/dist/ux/navigation-progress.js +245 -0
- package/dist/ux/navigation-progress.js.map +1 -0
- package/dist/ux/optimistic.d.ts +139 -0
- package/dist/ux/optimistic.d.ts.map +1 -0
- package/dist/ux/optimistic.js +242 -0
- package/dist/ux/optimistic.js.map +1 -0
- package/dist/ux/use-action.d.ts +60 -0
- package/dist/ux/use-action.d.ts.map +1 -0
- package/dist/ux/use-action.js +100 -0
- package/dist/ux/use-action.js.map +1 -0
- package/dist/ux/use-optimistic-list.d.ts +69 -0
- package/dist/ux/use-optimistic-list.d.ts.map +1 -0
- package/dist/ux/use-optimistic-list.js +115 -0
- package/dist/ux/use-optimistic-list.js.map +1 -0
- package/dist/ux/use-optimistic-value.d.ts +40 -0
- package/dist/ux/use-optimistic-value.d.ts.map +1 -0
- package/dist/ux/use-optimistic-value.js +68 -0
- package/dist/ux/use-optimistic-value.js.map +1 -0
- package/docs/ReleaseProcess.md +315 -0
- package/package.json +83 -11
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The patch queue behind the optimistic hooks — no React, no transport, no
|
|
3
|
+
* framework. All of the thinking is here so it can be tested as a function.
|
|
4
|
+
*
|
|
5
|
+
* ## The shape of the problem
|
|
6
|
+
*
|
|
7
|
+
* A screen shows rows the server sent. Someone ticks one. Three things then
|
|
8
|
+
* have to be true at once:
|
|
9
|
+
*
|
|
10
|
+
* 1. The tick shows **now**, before any request goes out.
|
|
11
|
+
* 2. If the write fails, the tick goes away again and the person is told.
|
|
12
|
+
* 3. When the server's own rows arrive, they win — including when they say
|
|
13
|
+
* something different from what was guessed.
|
|
14
|
+
*
|
|
15
|
+
* React's `useOptimistic` solves (1) and (2) for a server action, because the
|
|
16
|
+
* action *is* the transition and React knows when it ends. It does not solve
|
|
17
|
+
* (3) for an app that writes over `fetch` and then calls `router.refresh()`:
|
|
18
|
+
* `router.refresh()` is not awaitable, so the transition ends before the new
|
|
19
|
+
* rows land and the optimistic value snaps back to the stale one — a visible
|
|
20
|
+
* flash of the old answer on every single write. That is the reason this
|
|
21
|
+
* module exists rather than a wrapper around `useOptimistic`.
|
|
22
|
+
*
|
|
23
|
+
* ## How it works
|
|
24
|
+
*
|
|
25
|
+
* The server's rows are the *base*. Every optimistic change is a *patch* held
|
|
26
|
+
* beside the base, never merged into it. What the screen renders is the base
|
|
27
|
+
* with the patches applied, recomputed on each render.
|
|
28
|
+
*
|
|
29
|
+
* A patch is `pending` while its write is in flight, then either:
|
|
30
|
+
*
|
|
31
|
+
* - **dropped** — the write failed, so the base alone is the truth again; or
|
|
32
|
+
* - **settled** — the write landed. It keeps being applied, because the base
|
|
33
|
+
* is still the *old* rows until a refresh arrives, and taking the patch away
|
|
34
|
+
* at this point is precisely the flash we are avoiding.
|
|
35
|
+
*
|
|
36
|
+
* A settled patch is dropped when fresh rows arrive that account for it. That
|
|
37
|
+
* is the whole reconciliation rule, and it is deliberately evidence-based
|
|
38
|
+
* rather than timed: see `shouldDropSettled`.
|
|
39
|
+
*/
|
|
40
|
+
/** How a row is identified. Ids are strings here; numbers stringify fine. */
|
|
41
|
+
export type RowKey = string;
|
|
42
|
+
export type PatchKind = "insert" | "update" | "remove" | "move";
|
|
43
|
+
export interface Patch<T> {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly kind: PatchKind;
|
|
46
|
+
/**
|
|
47
|
+
* The row this patch concerns. For an insert this starts as a made-up key
|
|
48
|
+
* and becomes the server's own key once the write lands.
|
|
49
|
+
*/
|
|
50
|
+
readonly key: RowKey;
|
|
51
|
+
/** `insert`: the row to show. */
|
|
52
|
+
readonly row?: T;
|
|
53
|
+
/** `update`: the fields to lay over the row. */
|
|
54
|
+
readonly fields?: Partial<T>;
|
|
55
|
+
/** `insert`: which end of the list to show it at. */
|
|
56
|
+
readonly at?: "start" | "end";
|
|
57
|
+
/** `move`: the key this row now sits before; `null` means last. */
|
|
58
|
+
readonly before?: RowKey | null;
|
|
59
|
+
readonly settled: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* What the base said about `key` at the moment the write landed. Fresh rows
|
|
62
|
+
* are recognised by disagreeing with it — see `shouldDropSettled`.
|
|
63
|
+
*/
|
|
64
|
+
readonly witness?: T | null;
|
|
65
|
+
}
|
|
66
|
+
/** A patch id. Unique within a session; never stored or sent anywhere. */
|
|
67
|
+
export declare function nextPatchId(): string;
|
|
68
|
+
/**
|
|
69
|
+
* The rows to render: the server's, with every patch laid over them in the
|
|
70
|
+
* order they were made, so a later change to the same row wins.
|
|
71
|
+
*/
|
|
72
|
+
export declare function applyPatches<T>(base: readonly T[], patches: readonly Patch<T>[], keyOf: (row: T) => RowKey): readonly T[];
|
|
73
|
+
/** Shallow, own-keys equality. Enough to tell "the same row" from "new data". */
|
|
74
|
+
export declare function sameRow<T>(a: T | null | undefined, b: T | null | undefined): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Does the base already say what this patch says? A redundant patch has
|
|
77
|
+
* nothing left to show and can go.
|
|
78
|
+
*/
|
|
79
|
+
export declare function isRedundant<T>(patch: Patch<T>, base: readonly T[], keyOf: (row: T) => RowKey): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Whether a settled patch has been overtaken by fresh server rows.
|
|
82
|
+
*
|
|
83
|
+
* Two ways it can have been, and they cover the two endings the person cares
|
|
84
|
+
* about:
|
|
85
|
+
*
|
|
86
|
+
* - **The base agrees** — the refresh carried the change through. Dropping the
|
|
87
|
+
* patch changes nothing on screen, which is exactly the point.
|
|
88
|
+
* - **The base disagrees with what it said when the write landed** — new data
|
|
89
|
+
* for this row has arrived. It wins, whatever it says. This is the case
|
|
90
|
+
* where the server did something other than what was guessed (clamped a
|
|
91
|
+
* value, renamed on save, reordered), and the screen corrects itself.
|
|
92
|
+
*
|
|
93
|
+
* A base that has not changed at all leaves the patch applied, however long it
|
|
94
|
+
* has been there. There is no timeout on purpose: a screen that never refetches
|
|
95
|
+
* has no truth to fall back to, and snapping to stale rows after N seconds
|
|
96
|
+
* would be a bug that only shows up on slow connections.
|
|
97
|
+
*
|
|
98
|
+
* Only ever called when the base is a *different array* from the one the patch
|
|
99
|
+
* settled against, so a parent that rebuilds its rows on every render cannot
|
|
100
|
+
* shake patches loose.
|
|
101
|
+
*/
|
|
102
|
+
export declare function shouldDropSettled<T>(patch: Patch<T>, base: readonly T[], keyOf: (row: T) => RowKey): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* The queue after fresh rows arrived: pending writes are left alone, settled
|
|
105
|
+
* ones are dropped once the rows account for them.
|
|
106
|
+
*/
|
|
107
|
+
export declare function reconcile<T>(patches: readonly Patch<T>[], base: readonly T[], keyOf: (row: T) => RowKey): readonly Patch<T>[];
|
|
108
|
+
/**
|
|
109
|
+
* Mark a landed write as settled, taking the server's own answer over the
|
|
110
|
+
* guess where it sent one — the correction in "confirm or correct" — and
|
|
111
|
+
* noting what the base said, so the next refresh can be recognised.
|
|
112
|
+
*
|
|
113
|
+
* What it takes from that answer is deliberately narrow. A write endpoint
|
|
114
|
+
* often returns a *thinner* record than the list endpoint does — no joined
|
|
115
|
+
* learner, no counts — because the caller did not need them. Laying that over
|
|
116
|
+
* the row wholesale would blank the columns beside the one that was edited,
|
|
117
|
+
* which looks exactly like data loss and is the reason this is a `pick` and a
|
|
118
|
+
* merge rather than an assignment.
|
|
119
|
+
*/
|
|
120
|
+
export declare function settlePatch<T>(patch: Patch<T>, serverRow: T | undefined, base: readonly T[], keyOf: (row: T) => RowKey): Patch<T>;
|
|
121
|
+
/**
|
|
122
|
+
* Drop what a write that has just landed supersedes: earlier *settled* changes
|
|
123
|
+
* to the same rows. Writes still in flight stay — each has a request that can
|
|
124
|
+
* still come back refused and needs rolling back on its own.
|
|
125
|
+
*
|
|
126
|
+
* This happens on landing, never on queueing. A change that succeeded is still
|
|
127
|
+
* true while a later one is being attempted, and clearing it early means a
|
|
128
|
+
* failed second write rolls the first one back with it.
|
|
129
|
+
*/
|
|
130
|
+
export declare function supersede<T>(patches: readonly Patch<T>[], keys: ReadonlySet<RowKey>): readonly Patch<T>[];
|
|
131
|
+
/**
|
|
132
|
+
* A stand-in key for a row that does not exist server-side yet. Distinctive on
|
|
133
|
+
* purpose: if one of these ever reaches a request body or a URL, the resulting
|
|
134
|
+
* 404 should say why at a glance.
|
|
135
|
+
*/
|
|
136
|
+
export declare function tempKey(prefix?: string): string;
|
|
137
|
+
/** Whether a key came from `tempKey` — a row that is still being created. */
|
|
138
|
+
export declare function isTempKey(key: RowKey): boolean;
|
|
139
|
+
//# sourceMappingURL=optimistic.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"optimistic.d.ts","sourceRoot":"","sources":["../../src/ux/optimistic.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The patch queue behind the optimistic hooks — no React, no transport, no
|
|
3
|
+
* framework. All of the thinking is here so it can be tested as a function.
|
|
4
|
+
*
|
|
5
|
+
* ## The shape of the problem
|
|
6
|
+
*
|
|
7
|
+
* A screen shows rows the server sent. Someone ticks one. Three things then
|
|
8
|
+
* have to be true at once:
|
|
9
|
+
*
|
|
10
|
+
* 1. The tick shows **now**, before any request goes out.
|
|
11
|
+
* 2. If the write fails, the tick goes away again and the person is told.
|
|
12
|
+
* 3. When the server's own rows arrive, they win — including when they say
|
|
13
|
+
* something different from what was guessed.
|
|
14
|
+
*
|
|
15
|
+
* React's `useOptimistic` solves (1) and (2) for a server action, because the
|
|
16
|
+
* action *is* the transition and React knows when it ends. It does not solve
|
|
17
|
+
* (3) for an app that writes over `fetch` and then calls `router.refresh()`:
|
|
18
|
+
* `router.refresh()` is not awaitable, so the transition ends before the new
|
|
19
|
+
* rows land and the optimistic value snaps back to the stale one — a visible
|
|
20
|
+
* flash of the old answer on every single write. That is the reason this
|
|
21
|
+
* module exists rather than a wrapper around `useOptimistic`.
|
|
22
|
+
*
|
|
23
|
+
* ## How it works
|
|
24
|
+
*
|
|
25
|
+
* The server's rows are the *base*. Every optimistic change is a *patch* held
|
|
26
|
+
* beside the base, never merged into it. What the screen renders is the base
|
|
27
|
+
* with the patches applied, recomputed on each render.
|
|
28
|
+
*
|
|
29
|
+
* A patch is `pending` while its write is in flight, then either:
|
|
30
|
+
*
|
|
31
|
+
* - **dropped** — the write failed, so the base alone is the truth again; or
|
|
32
|
+
* - **settled** — the write landed. It keeps being applied, because the base
|
|
33
|
+
* is still the *old* rows until a refresh arrives, and taking the patch away
|
|
34
|
+
* at this point is precisely the flash we are avoiding.
|
|
35
|
+
*
|
|
36
|
+
* A settled patch is dropped when fresh rows arrive that account for it. That
|
|
37
|
+
* is the whole reconciliation rule, and it is deliberately evidence-based
|
|
38
|
+
* rather than timed: see `shouldDropSettled`.
|
|
39
|
+
*/
|
|
40
|
+
let counter = 0;
|
|
41
|
+
/** A patch id. Unique within a session; never stored or sent anywhere. */
|
|
42
|
+
export function nextPatchId() {
|
|
43
|
+
counter += 1;
|
|
44
|
+
return `p${counter}`;
|
|
45
|
+
}
|
|
46
|
+
function find(rows, key, keyOf) {
|
|
47
|
+
for (const row of rows)
|
|
48
|
+
if (keyOf(row) === key)
|
|
49
|
+
return row;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The rows to render: the server's, with every patch laid over them in the
|
|
54
|
+
* order they were made, so a later change to the same row wins.
|
|
55
|
+
*/
|
|
56
|
+
export function applyPatches(base, patches, keyOf) {
|
|
57
|
+
if (patches.length === 0)
|
|
58
|
+
return base;
|
|
59
|
+
let rows = [...base];
|
|
60
|
+
for (const patch of patches) {
|
|
61
|
+
switch (patch.kind) {
|
|
62
|
+
case "insert": {
|
|
63
|
+
// The server's own copy has arrived under this key: show that one
|
|
64
|
+
// rather than two of the same thing.
|
|
65
|
+
if (patch.row === undefined || find(rows, patch.key, keyOf) !== null)
|
|
66
|
+
break;
|
|
67
|
+
if (patch.at === "start")
|
|
68
|
+
rows.unshift(patch.row);
|
|
69
|
+
else
|
|
70
|
+
rows.push(patch.row);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "update": {
|
|
74
|
+
rows = rows.map((row) => (keyOf(row) === patch.key ? { ...row, ...patch.fields } : row));
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "remove": {
|
|
78
|
+
rows = rows.filter((row) => keyOf(row) !== patch.key);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "move": {
|
|
82
|
+
const from = rows.findIndex((row) => keyOf(row) === patch.key);
|
|
83
|
+
if (from < 0)
|
|
84
|
+
break;
|
|
85
|
+
const moved = rows.splice(from, 1);
|
|
86
|
+
// `splice` above removed exactly one row at a known index, so this is
|
|
87
|
+
// never empty; the check is for the type, not for the case.
|
|
88
|
+
if (moved.length === 0)
|
|
89
|
+
break;
|
|
90
|
+
const to = patch.before == null ? -1 : rows.findIndex((other) => keyOf(other) === patch.before);
|
|
91
|
+
rows.splice(to < 0 ? rows.length : to, 0, ...moved);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
/** The key that follows `key` in `rows`; `null` at the end. */
|
|
99
|
+
function neighbourAfter(rows, key, keyOf) {
|
|
100
|
+
const index = rows.findIndex((row) => keyOf(row) === key);
|
|
101
|
+
if (index < 0)
|
|
102
|
+
return null;
|
|
103
|
+
const next = rows[index + 1];
|
|
104
|
+
return next === undefined ? null : keyOf(next);
|
|
105
|
+
}
|
|
106
|
+
/** Shallow, own-keys equality. Enough to tell "the same row" from "new data". */
|
|
107
|
+
export function sameRow(a, b) {
|
|
108
|
+
if (a === b)
|
|
109
|
+
return true;
|
|
110
|
+
if (a == null || b == null)
|
|
111
|
+
return false;
|
|
112
|
+
if (typeof a !== "object" || typeof b !== "object")
|
|
113
|
+
return Object.is(a, b);
|
|
114
|
+
const left = a;
|
|
115
|
+
const right = b;
|
|
116
|
+
const keys = Object.keys(left);
|
|
117
|
+
if (keys.length !== Object.keys(right).length)
|
|
118
|
+
return false;
|
|
119
|
+
return keys.every((key) => Object.is(left[key], right[key]));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Does the base already say what this patch says? A redundant patch has
|
|
123
|
+
* nothing left to show and can go.
|
|
124
|
+
*/
|
|
125
|
+
export function isRedundant(patch, base, keyOf) {
|
|
126
|
+
const current = find(base, patch.key, keyOf);
|
|
127
|
+
switch (patch.kind) {
|
|
128
|
+
case "insert":
|
|
129
|
+
return current !== null;
|
|
130
|
+
case "remove":
|
|
131
|
+
return current === null;
|
|
132
|
+
case "update": {
|
|
133
|
+
// A row that is no longer there cannot be waiting for an edit.
|
|
134
|
+
if (current === null)
|
|
135
|
+
return true;
|
|
136
|
+
const fields = (patch.fields ?? {});
|
|
137
|
+
const row = current;
|
|
138
|
+
return Object.keys(fields).every((key) => Object.is(row[key], fields[key]));
|
|
139
|
+
}
|
|
140
|
+
case "move":
|
|
141
|
+
return find(base, patch.key, keyOf) !== null && neighbourAfter(base, patch.key, keyOf) === patch.before;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether a settled patch has been overtaken by fresh server rows.
|
|
146
|
+
*
|
|
147
|
+
* Two ways it can have been, and they cover the two endings the person cares
|
|
148
|
+
* about:
|
|
149
|
+
*
|
|
150
|
+
* - **The base agrees** — the refresh carried the change through. Dropping the
|
|
151
|
+
* patch changes nothing on screen, which is exactly the point.
|
|
152
|
+
* - **The base disagrees with what it said when the write landed** — new data
|
|
153
|
+
* for this row has arrived. It wins, whatever it says. This is the case
|
|
154
|
+
* where the server did something other than what was guessed (clamped a
|
|
155
|
+
* value, renamed on save, reordered), and the screen corrects itself.
|
|
156
|
+
*
|
|
157
|
+
* A base that has not changed at all leaves the patch applied, however long it
|
|
158
|
+
* has been there. There is no timeout on purpose: a screen that never refetches
|
|
159
|
+
* has no truth to fall back to, and snapping to stale rows after N seconds
|
|
160
|
+
* would be a bug that only shows up on slow connections.
|
|
161
|
+
*
|
|
162
|
+
* Only ever called when the base is a *different array* from the one the patch
|
|
163
|
+
* settled against, so a parent that rebuilds its rows on every render cannot
|
|
164
|
+
* shake patches loose.
|
|
165
|
+
*/
|
|
166
|
+
export function shouldDropSettled(patch, base, keyOf) {
|
|
167
|
+
if (isRedundant(patch, base, keyOf))
|
|
168
|
+
return true;
|
|
169
|
+
return !sameRow(find(base, patch.key, keyOf), patch.witness ?? null);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The queue after fresh rows arrived: pending writes are left alone, settled
|
|
173
|
+
* ones are dropped once the rows account for them.
|
|
174
|
+
*/
|
|
175
|
+
export function reconcile(patches, base, keyOf) {
|
|
176
|
+
if (patches.length === 0)
|
|
177
|
+
return patches;
|
|
178
|
+
const kept = patches.filter((patch) => !patch.settled || !shouldDropSettled(patch, base, keyOf));
|
|
179
|
+
return kept.length === patches.length ? patches : kept;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Mark a landed write as settled, taking the server's own answer over the
|
|
183
|
+
* guess where it sent one — the correction in "confirm or correct" — and
|
|
184
|
+
* noting what the base said, so the next refresh can be recognised.
|
|
185
|
+
*
|
|
186
|
+
* What it takes from that answer is deliberately narrow. A write endpoint
|
|
187
|
+
* often returns a *thinner* record than the list endpoint does — no joined
|
|
188
|
+
* learner, no counts — because the caller did not need them. Laying that over
|
|
189
|
+
* the row wholesale would blank the columns beside the one that was edited,
|
|
190
|
+
* which looks exactly like data loss and is the reason this is a `pick` and a
|
|
191
|
+
* merge rather than an assignment.
|
|
192
|
+
*/
|
|
193
|
+
export function settlePatch(patch, serverRow, base, keyOf) {
|
|
194
|
+
const settled = { ...patch, settled: true, witness: find(base, patch.key, keyOf) };
|
|
195
|
+
if (serverRow === null || serverRow === undefined || typeof serverRow !== "object")
|
|
196
|
+
return settled;
|
|
197
|
+
if (patch.kind === "insert" && patch.row !== undefined) {
|
|
198
|
+
// The guess fills in whatever the create did not answer with; the key
|
|
199
|
+
// comes from the server, because that is the one thing only it knows.
|
|
200
|
+
const row = { ...patch.row, ...serverRow };
|
|
201
|
+
const key = keyOf(row);
|
|
202
|
+
return { ...settled, key, row, witness: find(base, key, keyOf) };
|
|
203
|
+
}
|
|
204
|
+
if (patch.kind === "update" && patch.fields) {
|
|
205
|
+
// Only the fields this write set, and only those the answer carried: a
|
|
206
|
+
// price rounded on save, a title trimmed, a status the server chose.
|
|
207
|
+
const answer = serverRow;
|
|
208
|
+
const corrected = { ...patch.fields };
|
|
209
|
+
for (const field of Object.keys(corrected)) {
|
|
210
|
+
if (field in answer)
|
|
211
|
+
corrected[field] = answer[field];
|
|
212
|
+
}
|
|
213
|
+
return { ...settled, fields: corrected };
|
|
214
|
+
}
|
|
215
|
+
return settled;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Drop what a write that has just landed supersedes: earlier *settled* changes
|
|
219
|
+
* to the same rows. Writes still in flight stay — each has a request that can
|
|
220
|
+
* still come back refused and needs rolling back on its own.
|
|
221
|
+
*
|
|
222
|
+
* This happens on landing, never on queueing. A change that succeeded is still
|
|
223
|
+
* true while a later one is being attempted, and clearing it early means a
|
|
224
|
+
* failed second write rolls the first one back with it.
|
|
225
|
+
*/
|
|
226
|
+
export function supersede(patches, keys) {
|
|
227
|
+
return patches.filter((patch) => !(patch.settled && keys.has(patch.key)));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* A stand-in key for a row that does not exist server-side yet. Distinctive on
|
|
231
|
+
* purpose: if one of these ever reaches a request body or a URL, the resulting
|
|
232
|
+
* 404 should say why at a glance.
|
|
233
|
+
*/
|
|
234
|
+
export function tempKey(prefix = "tmp") {
|
|
235
|
+
counter += 1;
|
|
236
|
+
return `${prefix}:${counter}:${Date.now().toString(36)}`;
|
|
237
|
+
}
|
|
238
|
+
/** Whether a key came from `tempKey` — a row that is still being created. */
|
|
239
|
+
export function isTempKey(key) {
|
|
240
|
+
return key.startsWith("tmp:");
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=optimistic.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"optimistic.js","sourceRoot":"","sources":["../../src/ux/optimistic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AA+BH,IAAI,OAAO,GAAG,CAAC,CAAC;AAEhB,0EAA0E;AAC1E,MAAM,UAAU,WAAW;IACzB,OAAO,IAAI,CAAC,CAAC;IACb,OAAO,IAAI,OAAO,EAAE,CAAC;AACvB,CAAC;AAED,SAAS,IAAI,CAAI,IAAkB,EAAE,GAAW,EAAE,KAAyB;IACzE,KAAK,MAAM,GAAG,IAAI,IAAI;QAAE,IAAI,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG;YAAE,OAAO,GAAG,CAAC;IAC3D,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAC1B,IAAkB,EAClB,OAA4B,EAC5B,KAAyB;IAEzB,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEtC,IAAI,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;IACrB,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;YACnB,KAAK,QAAQ,CAAC,CAAC,CAAC;gBACd,kEAAkE;gBAClE,qCAAqC;gBACrC,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS,IAAI,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,KAAK,IAAI;oBAAE,MAAM;gBAC5E,IAAI,KAAK,CAAC,EAAE,KAAK,OAAO;oBAAE,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;;oBAC7C,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;gBAC1B,MAAM;YACR,CAAC;YACD,KAAK,QAAQ,CAAC,CAAC,CAAC;gBACd,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,GAAG,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;gBACzF,MAAM;YACR,CAAC;YACD,KAAK,QAAQ,CAAC,CAAC,CAAC;gBACd,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK,CAAC,GAAG,CAAC,CAAC;gBACtD,MAAM;YACR,CAAC;YACD,KAAK,MAAM,CAAC,CAAC,CAAC;gBACZ,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK,CAAC,GAAG,CAAC,CAAC;gBAC/D,IAAI,IAAI,GAAG,CAAC;oBAAE,MAAM;gBACpB,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;gBACnC,sEAAsE;gBACtE,4DAA4D;gBAC5D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;oBAAE,MAAM;gBAC9B,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,KAAK,CAAC,MAAM,CAAC,CAAC;gBAChG,IAAI,CAAC,MAAM,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,GAAG,KAAK,CAAC,CAAC;gBACpD,MAAM;YACR,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,+DAA+D;AAC/D,SAAS,cAAc,CAAI,IAAkB,EAAE,GAAW,EAAE,KAAyB;IACnF,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC;IAC1D,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAC3B,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;IAC7B,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACjD,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,OAAO,CAAI,CAAuB,EAAE,CAAuB;IACzE,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,IAAI;QAAE,OAAO,KAAK,CAAC;IACzC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3E,MAAM,IAAI,GAAG,CAA4B,CAAC;IAC1C,MAAM,KAAK,GAAG,CAA4B,CAAC;IAC3C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/B,IAAI,IAAI,CAAC,MAAM,KAAK,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC5D,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAC/D,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAI,KAAe,EAAE,IAAkB,EAAE,KAAyB;IAC3F,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC7C,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,QAAQ;YACX,OAAO,OAAO,KAAK,IAAI,CAAC;QAC1B,KAAK,QAAQ;YACX,OAAO,OAAO,KAAK,IAAI,CAAC;QAC1B,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,+DAA+D;YAC/D,IAAI,OAAO,KAAK,IAAI;gBAAE,OAAO,IAAI,CAAC;YAClC,MAAM,MAAM,GAAG,CAAC,KAAK,CAAC,MAAM,IAAI,EAAE,CAA4B,CAAC;YAC/D,MAAM,GAAG,GAAG,OAAkC,CAAC;YAC/C,OAAO,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAC9E,CAAC;QACD,KAAK,MAAM;YACT,OAAO,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,KAAK,IAAI,IAAI,cAAc,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,KAAK,KAAK,CAAC,MAAM,CAAC;IAC5G,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,iBAAiB,CAAI,KAAe,EAAE,IAAkB,EAAE,KAAyB;IACjG,IAAI,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACjD,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,KAAK,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC;AACvE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CACvB,OAA4B,EAC5B,IAAkB,EAClB,KAAyB;IAEzB,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,OAAO,CAAC;IACzC,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,iBAAiB,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;IACjG,OAAO,IAAI,CAAC,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;AACzD,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,WAAW,CACzB,KAAe,EACf,SAAwB,EACxB,IAAkB,EAClB,KAAyB;IAEzB,MAAM,OAAO,GAAG,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,CAAC;IACnF,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,SAAS,IAAI,OAAO,SAAS,KAAK,QAAQ;QAAE,OAAO,OAAO,CAAC;IAEnG,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;QACvD,sEAAsE;QACtE,sEAAsE;QACtE,MAAM,GAAG,GAAG,EAAE,GAAG,KAAK,CAAC,GAAG,EAAE,GAAG,SAAS,EAAE,CAAC;QAC3C,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QACvB,OAAO,EAAE,GAAG,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,EAAE,CAAC;IACnE,CAAC;IAED,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;QAC5C,uEAAuE;QACvE,qEAAqE;QACrE,MAAM,MAAM,GAAG,SAAoC,CAAC;QACpD,MAAM,SAAS,GAA4B,EAAE,GAAI,KAAK,CAAC,MAAkC,EAAE,CAAC;QAC5F,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YAC3C,IAAI,KAAK,IAAI,MAAM;gBAAE,SAAS,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QACxD,CAAC;QACD,OAAO,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,SAAuB,EAAE,CAAC;IACzD,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,SAAS,CAAI,OAA4B,EAAE,IAAyB;IAClF,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAC5E,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,OAAO,CAAC,MAAM,GAAG,KAAK;IACpC,OAAO,IAAI,CAAC,CAAC;IACb,OAAO,GAAG,MAAM,IAAI,OAAO,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;AAC3D,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,SAAS,CAAC,GAAW;IACnC,OAAO,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;AAChC,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface ActionOptions<R> {
|
|
2
|
+
/**
|
|
3
|
+
* What to say when it works. A function gets the result, and may return
|
|
4
|
+
* nothing for "this one is not worth a toast".
|
|
5
|
+
*/
|
|
6
|
+
success?: string | ((result: R) => string | null | undefined);
|
|
7
|
+
/** The heading when it fails; the thrown message becomes the detail. */
|
|
8
|
+
error?: string;
|
|
9
|
+
onSuccess?: (result: R) => void;
|
|
10
|
+
/**
|
|
11
|
+
* Runs *in addition* to the failure being reported — usually to put back
|
|
12
|
+
* whatever was changed ahead of the answer. Silencing the report is a
|
|
13
|
+
* separate decision, and a deliberate one: see `silent`.
|
|
14
|
+
*/
|
|
15
|
+
onError?: (error: unknown) => void;
|
|
16
|
+
/**
|
|
17
|
+
* Say nothing on failure. Only for a control that shows the refusal itself,
|
|
18
|
+
* in place. A rolled-back change with no explanation is the failure mode
|
|
19
|
+
* this whole layer exists to avoid.
|
|
20
|
+
*/
|
|
21
|
+
silent?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface Action<A extends unknown[], R> {
|
|
24
|
+
/**
|
|
25
|
+
* Run it. Never throws: a failure is reported and comes back as `undefined`,
|
|
26
|
+
* because the caller is an `onClick` and an unhandled rejection there is an
|
|
27
|
+
* error in the console and nothing on the screen.
|
|
28
|
+
*/
|
|
29
|
+
run: (...args: A) => Promise<R | undefined>;
|
|
30
|
+
/** In flight. Feed this straight to a button's `loading`. */
|
|
31
|
+
pending: boolean;
|
|
32
|
+
/** The last failure's wording, for showing it in place rather than in a toast. */
|
|
33
|
+
error: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One asynchronous thing a control does, with the pending flag that control
|
|
37
|
+
* needs and the reporting it would otherwise repeat.
|
|
38
|
+
*
|
|
39
|
+
* Re-entry is refused rather than queued. Two clicks on **Pay** are one
|
|
40
|
+
* intention, and the second is almost always the person telling you the first
|
|
41
|
+
* one gave them nothing to look at — which is the very problem the `pending`
|
|
42
|
+
* flag fixes.
|
|
43
|
+
*/
|
|
44
|
+
export declare function useAction<A extends unknown[], R>(action: (...args: A) => Promise<R>, options?: ActionOptions<R>): Action<A, R>;
|
|
45
|
+
export interface KeyedAction {
|
|
46
|
+
/** Run something for one row; a second run for the same row is refused. */
|
|
47
|
+
run: <R>(key: string, action: () => Promise<R>, options?: ActionOptions<R>) => Promise<R | undefined>;
|
|
48
|
+
isPending: (key: string) => boolean;
|
|
49
|
+
/** Any row busy at all — for disabling a whole toolbar. */
|
|
50
|
+
pending: boolean;
|
|
51
|
+
/** The busy rows, where a component would rather read the set itself. */
|
|
52
|
+
keys: ReadonlySet<string>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The same, for a list where each row has its own button. Keeps a set of busy
|
|
56
|
+
* keys so one row's spinner does not disable the rest of the table — the
|
|
57
|
+
* commonest small cruelty in a list screen.
|
|
58
|
+
*/
|
|
59
|
+
export declare function useKeyedAction(defaults?: ActionOptions<unknown>): KeyedAction;
|
|
60
|
+
//# sourceMappingURL=use-action.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-action.d.ts","sourceRoot":"","sources":["../../src/ux/use-action.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IAC9D,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,MAAM,CAAC,CAAC,SAAS,OAAO,EAAE,EAAE,CAAC;IAC5C;;;;OAIG;IACH,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IAC5C,6DAA6D;IAC7D,OAAO,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,OAAO,EAAE,EAAE,CAAC,EAC9C,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAClC,OAAO,GAAE,aAAa,CAAC,CAAC,CAAM,GAC7B,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAyCd;AAED,MAAM,WAAW,WAAW;IAC1B,2EAA2E;IAC3E,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IACtG,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACpC,2DAA2D;IAC3D,OAAO,EAAE,OAAO,CAAC;IACjB,yEAAyE;IACzE,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,GAAE,aAAa,CAAC,OAAO,CAAM,GAAG,WAAW,CAyCjF"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { errorMessage } from "./errors.js";
|
|
4
|
+
import { useActionReporter } from "./feedback.js";
|
|
5
|
+
/**
|
|
6
|
+
* One asynchronous thing a control does, with the pending flag that control
|
|
7
|
+
* needs and the reporting it would otherwise repeat.
|
|
8
|
+
*
|
|
9
|
+
* Re-entry is refused rather than queued. Two clicks on **Pay** are one
|
|
10
|
+
* intention, and the second is almost always the person telling you the first
|
|
11
|
+
* one gave them nothing to look at — which is the very problem the `pending`
|
|
12
|
+
* flag fixes.
|
|
13
|
+
*/
|
|
14
|
+
export function useAction(action, options = {}) {
|
|
15
|
+
const report = useActionReporter();
|
|
16
|
+
const [pending, setPending] = React.useState(false);
|
|
17
|
+
const [error, setError] = React.useState(null);
|
|
18
|
+
// The "latest ref" pattern: written after the render, so the asynchronous
|
|
19
|
+
// half always reads current values without any of these callbacks having to
|
|
20
|
+
// change identity — a callback that changes on every render is how a
|
|
21
|
+
// memoised row ends up re-rendering for nothing.
|
|
22
|
+
const latest = React.useRef({ action, options, report });
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
latest.current = { action, options, report };
|
|
25
|
+
});
|
|
26
|
+
const inFlight = React.useRef(false);
|
|
27
|
+
const run = React.useCallback(async (...args) => {
|
|
28
|
+
if (inFlight.current)
|
|
29
|
+
return undefined;
|
|
30
|
+
inFlight.current = true;
|
|
31
|
+
setPending(true);
|
|
32
|
+
setError(null);
|
|
33
|
+
const { action: fn, options: opts, report: tell } = latest.current;
|
|
34
|
+
try {
|
|
35
|
+
const result = await fn(...args);
|
|
36
|
+
const message = typeof opts.success === "function" ? opts.success(result) : opts.success;
|
|
37
|
+
if (message)
|
|
38
|
+
tell({ tone: "success", title: message });
|
|
39
|
+
opts.onSuccess?.(result);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
catch (thrown) {
|
|
43
|
+
const detail = errorMessage(thrown);
|
|
44
|
+
setError(detail);
|
|
45
|
+
opts.onError?.(thrown);
|
|
46
|
+
if (!opts.silent)
|
|
47
|
+
tell({ tone: "error", title: opts.error ?? "That didn't work", description: detail });
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
inFlight.current = false;
|
|
52
|
+
setPending(false);
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
return { run, pending, error };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The same, for a list where each row has its own button. Keeps a set of busy
|
|
59
|
+
* keys so one row's spinner does not disable the rest of the table — the
|
|
60
|
+
* commonest small cruelty in a list screen.
|
|
61
|
+
*/
|
|
62
|
+
export function useKeyedAction(defaults = {}) {
|
|
63
|
+
const report = useActionReporter();
|
|
64
|
+
const [keys, setKeys] = React.useState(() => new Set());
|
|
65
|
+
const latest = React.useRef({ defaults, report });
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
latest.current = { defaults, report };
|
|
68
|
+
});
|
|
69
|
+
const busy = React.useRef(new Set());
|
|
70
|
+
const run = React.useCallback(async (key, action, options) => {
|
|
71
|
+
if (busy.current.has(key))
|
|
72
|
+
return undefined;
|
|
73
|
+
busy.current.add(key);
|
|
74
|
+
setKeys(new Set(busy.current));
|
|
75
|
+
const { defaults: base, report: tell } = latest.current;
|
|
76
|
+
const opts = { ...base, ...options };
|
|
77
|
+
try {
|
|
78
|
+
const result = await action();
|
|
79
|
+
const message = typeof opts.success === "function" ? opts.success(result) : opts.success;
|
|
80
|
+
if (message)
|
|
81
|
+
tell({ tone: "success", title: message });
|
|
82
|
+
opts.onSuccess?.(result);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
catch (thrown) {
|
|
86
|
+
opts.onError?.(thrown);
|
|
87
|
+
if (!opts.silent) {
|
|
88
|
+
tell({ tone: "error", title: opts.error ?? "That didn't work", description: errorMessage(thrown) });
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
busy.current.delete(key);
|
|
94
|
+
setKeys(new Set(busy.current));
|
|
95
|
+
}
|
|
96
|
+
}, []);
|
|
97
|
+
const isPending = React.useCallback((key) => keys.has(key), [keys]);
|
|
98
|
+
return { run, isPending, pending: keys.size > 0, keys };
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=use-action.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-action.js","sourceRoot":"","sources":["../../src/ux/use-action.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { tempKey, type RowKey } from "./optimistic.js";
|
|
2
|
+
export interface OptimisticListOptions<T> {
|
|
3
|
+
/** How a row is identified. Must be stable for the life of the row. */
|
|
4
|
+
key: (row: T) => RowKey;
|
|
5
|
+
/** Heading for the toast when a write fails. */
|
|
6
|
+
error?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Run after a write lands. This is where the caller refetches — the hook
|
|
9
|
+
* cannot know whether that is `router.refresh()`, a reload callback or
|
|
10
|
+
* nothing at all, and it deliberately keeps working if it is nothing.
|
|
11
|
+
*/
|
|
12
|
+
onSettled?: () => void;
|
|
13
|
+
}
|
|
14
|
+
/** Per-call overrides. */
|
|
15
|
+
export interface WriteOptions {
|
|
16
|
+
/** What to say when it works. Nothing said by default: the row moving is the feedback. */
|
|
17
|
+
success?: string;
|
|
18
|
+
/** Heading when it fails. */
|
|
19
|
+
error?: string;
|
|
20
|
+
/** Skip the list-level `onSettled` for this one write. */
|
|
21
|
+
silentSettle?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface OptimisticList<T> {
|
|
24
|
+
/** What to render: the server's rows with every un-dropped change laid over. */
|
|
25
|
+
items: T[];
|
|
26
|
+
/** This row has a write in flight — dim it, spin its button, leave the rest alone. */
|
|
27
|
+
isPending: (key: RowKey) => boolean;
|
|
28
|
+
/** Anything at all in flight. */
|
|
29
|
+
pending: boolean;
|
|
30
|
+
/** Show a new row now; the commit's returned row replaces the stand-in. */
|
|
31
|
+
insert: (row: T, commit: () => Promise<T | void>, options?: WriteOptions) => Promise<boolean>;
|
|
32
|
+
/** Show an edit now. The commit may return the row as the server has it. */
|
|
33
|
+
update: (key: RowKey, fields: Partial<T>, commit: () => Promise<T | void>, options?: WriteOptions) => Promise<boolean>;
|
|
34
|
+
/** Take a row off the list now. */
|
|
35
|
+
remove: (key: RowKey, commit: () => Promise<unknown>, options?: WriteOptions) => Promise<boolean>;
|
|
36
|
+
/** Put a row before another one (`null` = last) now. */
|
|
37
|
+
move: (key: RowKey, before: RowKey | null, commit: () => Promise<unknown>, options?: WriteOptions) => Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Several rows under one write — "mark all read", "archive the selected".
|
|
40
|
+
* They settle and roll back together, because the request did.
|
|
41
|
+
*/
|
|
42
|
+
bulk: (changes: readonly {
|
|
43
|
+
key: RowKey;
|
|
44
|
+
fields: Partial<T>;
|
|
45
|
+
}[], commit: () => Promise<unknown>, options?: WriteOptions) => Promise<boolean>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A list the screen can change before the server has agreed.
|
|
49
|
+
*
|
|
50
|
+
* `base` is whatever the server last said — a server component's prop, or
|
|
51
|
+
* state filled by a fetch. Never write to it: the hook keeps its changes
|
|
52
|
+
* beside it and folds them in on render, which is what lets fresh rows take
|
|
53
|
+
* over cleanly whenever they turn up. See `optimistic.ts` for the rules.
|
|
54
|
+
*
|
|
55
|
+
* ```tsx
|
|
56
|
+
* const list = useOptimisticList(tasks, { key: (t) => t.id, onSettled: router.refresh });
|
|
57
|
+
*
|
|
58
|
+
* <Checkbox
|
|
59
|
+
* checked={task.done}
|
|
60
|
+
* disabled={list.isPending(task.id)}
|
|
61
|
+
* onCheckedChange={(done) =>
|
|
62
|
+
* list.update(task.id, { done }, () => commitApi(`/api/tasks/${task.id}`, { method: "PATCH", … }))
|
|
63
|
+
* }
|
|
64
|
+
* />
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export declare function useOptimisticList<T>(base: readonly T[], options: OptimisticListOptions<T>): OptimisticList<T>;
|
|
68
|
+
export { tempKey };
|
|
69
|
+
//# sourceMappingURL=use-optimistic-list.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-optimistic-list.d.ts","sourceRoot":"","sources":["../../src/ux/use-optimistic-list.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,OAAO,EAEP,KAAK,MAAM,EACZ,MAAM,iBAAiB,CAAC;AAIzB,MAAM,WAAW,qBAAqB,CAAC,CAAC;IACtC,uEAAuE;IACvE,GAAG,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC;IACxB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAED,0BAA0B;AAC1B,MAAM,WAAW,YAAY;IAC3B,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,gFAAgF;IAChF,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,sFAAsF;IACtF,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACpC,iCAAiC;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,OAAO,CAAC,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9F,4EAA4E;IAC5E,MAAM,EAAE,CACN,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,EAClB,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,EAC/B,OAAO,CAAC,EAAE,YAAY,KACnB,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB,mCAAmC;IACnC,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAClG,wDAAwD;IACxD,IAAI,EAAE,CACJ,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAC9B,OAAO,CAAC,EAAE,YAAY,KACnB,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB;;;OAGG;IACH,IAAI,EAAE,CACJ,OAAO,EAAE,SAAS;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;KAAE,EAAE,EACvD,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAC9B,OAAO,CAAC,EAAE,YAAY,KACnB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CA8H7G;AAED,OAAO,EAAE,OAAO,EAAE,CAAC"}
|