@connextar/house 0.2.0 → 0.4.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 (63) hide show
  1. package/README.md +213 -2
  2. package/dist/house.css +66 -0
  3. package/dist/ux/errors.d.ts +26 -0
  4. package/dist/ux/errors.d.ts.map +1 -0
  5. package/dist/ux/errors.js +33 -0
  6. package/dist/ux/errors.js.map +1 -0
  7. package/dist/ux/feedback.d.ts +23 -0
  8. package/dist/ux/feedback.d.ts.map +1 -0
  9. package/dist/ux/feedback.js +22 -0
  10. package/dist/ux/feedback.js.map +1 -0
  11. package/dist/ux/index.d.ts +20 -0
  12. package/dist/ux/index.d.ts.map +1 -0
  13. package/dist/ux/index.js +20 -0
  14. package/dist/ux/index.js.map +1 -0
  15. package/dist/ux/navigation-index.d.ts +10 -0
  16. package/dist/ux/navigation-index.d.ts.map +1 -0
  17. package/dist/ux/navigation-index.js +10 -0
  18. package/dist/ux/navigation-index.js.map +1 -0
  19. package/dist/ux/navigation-progress.d.ts +53 -0
  20. package/dist/ux/navigation-progress.d.ts.map +1 -0
  21. package/dist/ux/navigation-progress.js +245 -0
  22. package/dist/ux/navigation-progress.js.map +1 -0
  23. package/dist/ux/optimistic.d.ts +139 -0
  24. package/dist/ux/optimistic.d.ts.map +1 -0
  25. package/dist/ux/optimistic.js +242 -0
  26. package/dist/ux/optimistic.js.map +1 -0
  27. package/dist/ux/use-action.d.ts +60 -0
  28. package/dist/ux/use-action.d.ts.map +1 -0
  29. package/dist/ux/use-action.js +100 -0
  30. package/dist/ux/use-action.js.map +1 -0
  31. package/dist/ux/use-optimistic-list.d.ts +69 -0
  32. package/dist/ux/use-optimistic-list.d.ts.map +1 -0
  33. package/dist/ux/use-optimistic-list.js +115 -0
  34. package/dist/ux/use-optimistic-list.js.map +1 -0
  35. package/dist/ux/use-optimistic-value.d.ts +40 -0
  36. package/dist/ux/use-optimistic-value.d.ts.map +1 -0
  37. package/dist/ux/use-optimistic-value.js +68 -0
  38. package/dist/ux/use-optimistic-value.js.map +1 -0
  39. package/dist/wizard/index.d.ts +26 -0
  40. package/dist/wizard/index.d.ts.map +1 -0
  41. package/dist/wizard/index.js +26 -0
  42. package/dist/wizard/index.js.map +1 -0
  43. package/dist/wizard/invalid.d.ts +91 -0
  44. package/dist/wizard/invalid.d.ts.map +1 -0
  45. package/dist/wizard/invalid.js +121 -0
  46. package/dist/wizard/invalid.js.map +1 -0
  47. package/dist/wizard/react-hook-form-index.d.ts +12 -0
  48. package/dist/wizard/react-hook-form-index.d.ts.map +1 -0
  49. package/dist/wizard/react-hook-form-index.js +12 -0
  50. package/dist/wizard/react-hook-form-index.js.map +1 -0
  51. package/dist/wizard/rhf-adapter.d.ts +32 -0
  52. package/dist/wizard/rhf-adapter.d.ts.map +1 -0
  53. package/dist/wizard/rhf-adapter.js +58 -0
  54. package/dist/wizard/rhf-adapter.js.map +1 -0
  55. package/dist/wizard/rules.d.ts +92 -0
  56. package/dist/wizard/rules.d.ts.map +1 -0
  57. package/dist/wizard/rules.js +105 -0
  58. package/dist/wizard/rules.js.map +1 -0
  59. package/dist/wizard/wizard.d.ts +127 -0
  60. package/dist/wizard/wizard.d.ts.map +1 -0
  61. package/dist/wizard/wizard.js +195 -0
  62. package/dist/wizard/wizard.js.map +1 -0
  63. package/package.json +24 -3
@@ -0,0 +1,245 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { usePathname, useRouter, useSearchParams } from "next/navigation.js";
4
+ import * as React from "react";
5
+ /**
6
+ * Feedback for the other half of "nothing answers my click": moving between
7
+ * screens.
8
+ *
9
+ * Two signals, because they answer two different questions:
10
+ *
11
+ * - **A bar across the top of the page** — "something is happening". It waits
12
+ * a moment before appearing so a fast navigation does not flash it.
13
+ * - **A spinner on the thing that was clicked** — "*that* is what is
14
+ * happening". Without it, a slow route on a page full of links gives no clue
15
+ * which one took, which is when people click a second one.
16
+ *
17
+ * The second is set on the DOM node directly rather than through React state.
18
+ * The click can land on any link anywhere in the tree, including ones rendered
19
+ * by pages this component knows nothing about, and an attribute is the only
20
+ * thing they all have in common. `data-busy` is the same attribute the button
21
+ * primitive sets for an action, so one rule in the stylesheet covers both.
22
+ */
23
+ /** Delay before the bar appears, so fast navigations do not flash it. */
24
+ const START_DELAY_MS = 140;
25
+ /** Cadence of the trickle that creeps the bar towards 90%. */
26
+ const TRICKLE_MS = 220;
27
+ /** Safety net: never leave the bar running forever if a navigation never commits. */
28
+ const MAX_PENDING_MS = 20_000;
29
+ /** How long the completed bar stays at 100% before fading out. */
30
+ const COMPLETE_MS = 260;
31
+ const START_EVENT = "cx:navigation-start";
32
+ /** The attribute every busy control carries, action or navigation alike. */
33
+ export const BUSY_ATTRIBUTE = "data-busy";
34
+ /** The control currently marked as the reason for a navigation. */
35
+ let marked = null;
36
+ function unmark() {
37
+ if (!marked)
38
+ return;
39
+ marked.removeAttribute(BUSY_ATTRIBUTE);
40
+ marked.removeAttribute("aria-busy");
41
+ marked = null;
42
+ }
43
+ function mark(element) {
44
+ if (marked === element)
45
+ return;
46
+ unmark();
47
+ if (!element)
48
+ return;
49
+ marked = element;
50
+ element.setAttribute(BUSY_ATTRIBUTE, "true");
51
+ element.setAttribute("aria-busy", "true");
52
+ }
53
+ /**
54
+ * Show the navigation feedback for something this module cannot see on its
55
+ * own — a programmatic `router.push`. Pass the control that caused it and it
56
+ * gets the spinner too.
57
+ */
58
+ export function startNavigation(element) {
59
+ if (typeof window === "undefined")
60
+ return;
61
+ mark(element ?? null);
62
+ window.dispatchEvent(new Event(START_EVENT));
63
+ }
64
+ /** Backwards-compatible alias for the original name. */
65
+ export const startRouteProgress = startNavigation;
66
+ /**
67
+ * `useRouter`, with the navigation feedback attached to `push` and `replace`.
68
+ *
69
+ * A click that runs `router.push(href)` is invisible to the watcher above —
70
+ * there is no anchor and no popstate — so those navigations were the ones with
71
+ * no feedback at all. The worst case is the one right after a save: the
72
+ * button's own spinner stops when the write lands, and then nothing happens
73
+ * for as long as the next screen takes to render.
74
+ *
75
+ * Swapping the import is the whole change; no call site moves. `refresh`,
76
+ * `back`, `forward` and `prefetch` pass straight through — a refresh re-renders
77
+ * the screen you are already on, which needs no bar.
78
+ */
79
+ export function useAppRouter() {
80
+ const router = useRouter();
81
+ return React.useMemo(() => ({
82
+ ...router,
83
+ // Forwarded by spread, not by naming the options: passing an explicit
84
+ // `undefined` is a different call from passing nothing.
85
+ push: (...args) => {
86
+ startNavigation();
87
+ router.push(...args);
88
+ },
89
+ replace: (...args) => {
90
+ startNavigation();
91
+ router.replace(...args);
92
+ },
93
+ }), [router]);
94
+ }
95
+ /**
96
+ * `router.push` with the feedback attached, and the control that caused it
97
+ * marked as busy. For a button that navigates rather than saves.
98
+ */
99
+ export function useNavigate() {
100
+ const router = useRouter();
101
+ return React.useCallback((href, options) => {
102
+ startNavigation(options?.element ?? null);
103
+ if (options?.replace)
104
+ router.replace(href);
105
+ else
106
+ router.push(href);
107
+ }, [router]);
108
+ }
109
+ /**
110
+ * A click that means "take me there", rather than one the browser will handle
111
+ * itself — a new tab, a download, a context menu.
112
+ *
113
+ * Deliberately *not* a `defaultPrevented` check. This runs in the capture
114
+ * phase, and the reason is the whole point of this listener: Next's `Link`
115
+ * calls `preventDefault()` on its way to a client-side navigation, so by the
116
+ * time a bubble-phase listener sees the click, every in-app link looks
117
+ * cancelled. Guarding on `defaultPrevented` there means the bar fires for
118
+ * exactly the navigations that do not need it and stays silent for all the
119
+ * ones that do — which is what was happening here until this was measured in
120
+ * the browser.
121
+ */
122
+ function isPlainLeftClick(event) {
123
+ return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
124
+ }
125
+ function navigatesElsewhere(url) {
126
+ if (url.origin !== window.location.origin)
127
+ return false;
128
+ // API routes are downloads/exports, not page navigations.
129
+ if (url.pathname.startsWith("/api/"))
130
+ return false;
131
+ return url.pathname !== window.location.pathname || url.search !== window.location.search;
132
+ }
133
+ function NavigationProgressBar({ className }) {
134
+ const pathname = usePathname();
135
+ const search = useSearchParams().toString();
136
+ const [progress, setProgress] = React.useState(null);
137
+ const timers = React.useRef([]);
138
+ const active = React.useRef(false);
139
+ const clearTimers = React.useCallback(() => {
140
+ timers.current.forEach((id) => window.clearTimeout(id));
141
+ timers.current = [];
142
+ }, []);
143
+ const stop = React.useCallback(() => {
144
+ clearTimers();
145
+ active.current = false;
146
+ delete document.body.dataset.routePending;
147
+ unmark();
148
+ setProgress((value) => (value === null ? null : 100));
149
+ window.setTimeout(() => {
150
+ // A new navigation may have started in the meantime; leave its bar alone.
151
+ if (!active.current)
152
+ setProgress(null);
153
+ }, COMPLETE_MS);
154
+ }, [clearTimers]);
155
+ const start = React.useCallback(() => {
156
+ if (active.current)
157
+ return;
158
+ active.current = true;
159
+ document.body.dataset.routePending = "true";
160
+ const trickle = () => {
161
+ setProgress((value) => (value === null ? value : Math.min(value + Math.max(1, (90 - value) * 0.16), 90)));
162
+ timers.current.push(window.setTimeout(trickle, TRICKLE_MS));
163
+ };
164
+ timers.current.push(window.setTimeout(() => {
165
+ setProgress(8);
166
+ trickle();
167
+ }, START_DELAY_MS));
168
+ timers.current.push(window.setTimeout(stop, MAX_PENDING_MS));
169
+ }, [stop]);
170
+ // The navigation committed once the URL the router reports has changed.
171
+ const initialRender = React.useRef(true);
172
+ React.useEffect(() => {
173
+ if (initialRender.current) {
174
+ initialRender.current = false;
175
+ return;
176
+ }
177
+ stop();
178
+ }, [pathname, search, stop]);
179
+ React.useEffect(() => {
180
+ const onClick = (event) => {
181
+ if (!isPlainLeftClick(event))
182
+ return;
183
+ const target = event.target;
184
+ if (!(target instanceof Element))
185
+ return;
186
+ const anchor = target.closest("a");
187
+ if (!anchor?.href || anchor.hasAttribute("download"))
188
+ return;
189
+ if (anchor.target && anchor.target !== "_self")
190
+ return;
191
+ if (!navigatesElsewhere(new URL(anchor.href)))
192
+ return;
193
+ // Mark before `start`, which returns early when a navigation is already
194
+ // running: a second click during a slow load should still move the
195
+ // spinner to the link that was actually clicked.
196
+ mark(anchor);
197
+ start();
198
+ };
199
+ const onSubmit = (event) => {
200
+ if (event.defaultPrevented)
201
+ return;
202
+ const form = event.target;
203
+ if (!(form instanceof HTMLFormElement))
204
+ return;
205
+ if (form.method.toLowerCase() !== "get" || !form.action)
206
+ return;
207
+ if (new URL(form.action).origin !== window.location.origin)
208
+ return;
209
+ if (event.submitter)
210
+ mark(event.submitter);
211
+ start();
212
+ };
213
+ // Capture, so this runs before `Link` cancels the click (see above). The
214
+ // submit listener stays in the bubble phase on purpose: a form with an
215
+ // `onSubmit` that saves rather than navigates is the common case here, and
216
+ // `defaultPrevented` is exactly how to tell those apart.
217
+ document.addEventListener("click", onClick, true);
218
+ document.addEventListener("submit", onSubmit);
219
+ window.addEventListener("popstate", start);
220
+ window.addEventListener(START_EVENT, start);
221
+ return () => {
222
+ document.removeEventListener("click", onClick, true);
223
+ document.removeEventListener("submit", onSubmit);
224
+ window.removeEventListener("popstate", start);
225
+ window.removeEventListener(START_EVENT, start);
226
+ clearTimers();
227
+ unmark();
228
+ delete document.body.dataset.routePending;
229
+ };
230
+ }, [clearTimers, start]);
231
+ if (progress === null)
232
+ return null;
233
+ return (_jsx("div", { className: "pointer-events-none fixed inset-x-0 top-0 z-[100] h-0.5", role: "progressbar", "aria-label": "Loading page", "aria-busy": "true", children: _jsx("div", { className: className, style: { width: `${progress}%`, opacity: progress === 100 ? 0 : 1 } }) }));
234
+ }
235
+ /**
236
+ * Mount once, above everything else — including anything that suspends, or the
237
+ * bar disappears for exactly the navigations it is there for.
238
+ *
239
+ * `className` styles the filled part of the bar, so the host app picks its own
240
+ * colour without this module knowing any of its tokens.
241
+ */
242
+ export function NavigationProgress({ className }) {
243
+ return (_jsx(React.Suspense, { fallback: null, children: _jsx(NavigationProgressBar, { className: className }) }));
244
+ }
245
+ //# sourceMappingURL=navigation-progress.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation-progress.js","sourceRoot":"","sources":["../../src/ux/navigation-progress.tsx"],"names":[],"mappings":"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"}
@@ -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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,6EAA6E;AAC7E,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC;AAE5B,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEhE,MAAM,WAAW,KAAK,CAAC,CAAC;IACtB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACjB,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAC7B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,CAAC,EAAE,OAAO,GAAG,KAAK,CAAC;IAC9B,mEAAmE;IACnE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC;CAC7B;AAID,0EAA0E;AAC1E,wBAAgB,WAAW,IAAI,MAAM,CAGpC;AAOD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAC5B,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,OAAO,EAAE,SAAS,KAAK,CAAC,CAAC,CAAC,EAAE,EAC5B,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,GACxB,SAAS,CAAC,EAAE,CAoCd;AAUD,iFAAiF;AACjF,wBAAgB,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CASpF;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,GAAG,OAAO,CAiBtG;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,GAAG,OAAO,CAG5G;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,CAAC,EACzB,OAAO,EAAE,SAAS,KAAK,CAAC,CAAC,CAAC,EAAE,EAC5B,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,GACxB,SAAS,KAAK,CAAC,CAAC,CAAC,EAAE,CAIrB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC3B,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EACf,SAAS,EAAE,CAAC,GAAG,SAAS,EACxB,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,GACxB,KAAK,CAAC,CAAC,CAAC,CAwBV;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,SAAS,KAAK,CAAC,CAAC,CAAC,EAAE,CAEzG;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,MAAM,SAAQ,GAAG,MAAM,CAG9C;AAED,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE9C"}
@@ -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":"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"}