@lotics/ui 16.0.0 → 16.1.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/AGENTS.md CHANGED
@@ -15,7 +15,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` for a custom-code app's `vite.config.ts`). Read before building any screen; reuse first. |
18
- | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates. |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split (modify → review-before-apply; create → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
21
21
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
package/docs/catalog.md CHANGED
@@ -611,7 +611,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
611
611
  `controls="buttons"`; KEYBOARD focus on the closed view opens edit mode with the input
612
612
  focused (type → Tab → type — see the data-entry keyboard contract), pointer focus never
613
613
  does; **`variant: "form" | "cell"`** is THE axis that separates a form field from a data-grid
614
- cell (see below).
614
+ cell (see below). Every commit registers in **`pending_commits`**, which
615
+ `Button`/`IconButton` wait on via **`use_gated_press`** so an action pressed in the same
616
+ gesture as the blur cannot read the record before the edit lands (data_entry.md § Inline
617
+ edit) — automatic, nothing to pass.
615
618
  - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
616
619
  **`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
617
620
  (`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
@@ -49,6 +49,31 @@ chip, hover reveals the input-family border (no extra grey wash, no pencil icon
49
49
  layout), click OR keyboard focus swaps the input in **at the same height** (zero reflow, the whole
50
50
  point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ / ✕).
51
51
 
52
+ **Commit-on-blur would race the press that caused it — the kit closes that race for you.**
53
+ Pressing an action while an inline field is focused starts TWO things in ONE gesture: the
54
+ field's write (the blur, on mousedown) and the button's handler (on mouseup). A handler that
55
+ RE-READS the record — to print a document, mint an invoice, copy the row into a new one — would
56
+ overtake the still-in-flight write and act on the PRE-EDIT value, silently: the new value on
57
+ screen, the old one in the output. So **`useInlineEdit` registers every commit and
58
+ `Button`/`IconButton` hold their press until the commits already in flight have settled**
59
+ (`pending_commits.ts`), showing their busy spinner while they wait. Nothing to wire up: with no
60
+ commit pending the press dispatches synchronously exactly as before, and a FAILED commit
61
+ releases the gate too (the field surfaces its own error). A commit started after the press was
62
+ taken is deliberately not waited for — the gate holds a press for the edit that caused it, never
63
+ for later work.
64
+
65
+ **What the gate cannot see — an action must take an ID, not a captured object.** Holding the
66
+ press makes the STORE current; it cannot refresh a value the handler already closed over.
67
+ `onPress={() => onIssue(invoice)}` captures the invoice as it was at press time, so the
68
+ confirm dialog quotes — and the mint bills — the pre-edit total no matter how long the gate
69
+ waits. Pass the KEY and resolve the object where it is used
70
+ (`onPress={() => onIssue(invoice.key)}`, then `invoices.find(…)` at render): worked in
71
+ `tpl_record`'s Billing band, whose charge cells are inline editors and whose Issue is
72
+ irreversible. The same rule kills the variant with no race at all — a handler reading a server
73
+ row the screen copied into `useState` (`app-sdk` `docs/mutations.md` § A read must not overtake
74
+ an in-flight write) — as does a write started by something other than an inline commit (a
75
+ dialog save, a debounced autosave).
76
+
52
77
  ### Keyboard entry — type → Tab → type
53
78
 
54
79
  Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
package/docs/templates.md CHANGED
@@ -329,7 +329,10 @@ billing, and quick-capture templates. Top → bottom:
329
329
  scope, once — a band-local problem (charged line missing its method) in the band, a broken
330
330
  record premise (no customer / invalid tax ID) at the section top; self-evident empties
331
331
  stay silent, and NEVER prose beside a CTA. Issuing gates on record premises only — never
332
- silently on stage.
332
+ silently on stage. It is also the worked example of **an irreversible action taking an ID,
333
+ not a captured object**: `onIssue(inv.key)` with the invoice resolved where the confirm
334
+ renders, so the quoted total is the one that will be billed even when the press was held
335
+ for a charge cell's write (data_entry.md § Inline edit).
333
336
  - **Document set — the OUTPUT desk, the last WORK section** (the composition rules' output
334
337
  law worked on the record surface: the top is intake, the bottom produces on demand).
335
338
  Forms group PER PARTY on the `SubsectionStack` beat — each party a `SubsectionHeading` +
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import type { UIMessagePart, UIDataTypes, UITools } from "ai";
4
4
  import { Text } from "@lotics/ui/text";
5
- import { colors, type ColorName } from "@lotics/ui/colors";
5
+ import { colors, solid, type ColorName } from "@lotics/ui/colors";
6
6
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
7
7
  import { Alert } from "@lotics/ui/alert";
8
8
  import { Badge } from "@lotics/ui/badge";
@@ -1391,12 +1391,12 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1391
1391
  {[...landed].sort((a, b) => Number(b.refFlagged) - Number(a.refFlagged)).map((l) => (
1392
1392
  <ListItem
1393
1393
  key={l.id}
1394
- left={<Icon name={l.refFlagged ? "circle-alert" : "circle-check"} size={18} color={l.refFlagged ? colors.amber[600] : colors.green[600]} />}
1394
+ left={<Icon name={l.refFlagged ? "circle-alert" : "circle-check"} size={18} color={l.refFlagged ? solid("amber") : solid("green")} />}
1395
1395
  title={`Created ${l.title}`}
1396
1396
  description={l.detail}
1397
1397
  right={
1398
1398
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1399
- {l.refFlagged ? <Text size="xs" weight="medium" style={{ color: colors.amber[600] }}>1 to check</Text> : null}
1399
+ {l.refFlagged ? <Text size="xs" weight="medium" style={{ color: solid("amber") }}>1 to check</Text> : null}
1400
1400
  <Icon name="chevron-right" size={16} color={colors.zinc[400]} />
1401
1401
  </View>
1402
1402
  }
@@ -345,7 +345,10 @@ function InvoiceBand({
345
345
  gated: boolean;
346
346
  onAmount: (chKey: string, v: number) => Promise<void>;
347
347
  onMethod: (chKey: string, m: Method) => Promise<void>;
348
- onIssue: (inv: Invoice) => void;
348
+ /** Takes the invoice's KEY, never the `inv` object: the press may be held while
349
+ * a charge cell finishes writing (Button waits out an inline commit), and a
350
+ * captured object would still be the pre-edit snapshot. Resolve at use. */
351
+ onIssue: (invKey: string) => void;
349
352
  }) {
350
353
  const total = invoiceTotal(inv);
351
354
  const state = invoiceStatus(inv);
@@ -410,10 +413,10 @@ function InvoiceBand({
410
413
  {state === "issued" ? (
411
414
  <>
412
415
  <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
413
- <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv)} />
416
+ <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv.key)} />
414
417
  </>
415
418
  ) : (
416
- <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv)} />
419
+ <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv.key)} />
417
420
  )}
418
421
  </View>
419
422
  </Subsection>
@@ -832,7 +835,9 @@ export function TplRecord() {
832
835
  // ── billing — the bill-to tax ID comes off the CUSTOMER
833
836
  const [invoices, setInvoices] = useState<Invoice[]>(BILLING_INITIAL);
834
837
  const [deposit, setDeposit] = useState(0);
835
- const [confirmIssue, setConfirmIssue] = useState<Invoice | null>(null);
838
+ // The key, not the invoice: what the dialog shows and issues is derived below, so a
839
+ // charge that landed between the press and the confirm is reflected, never a stale total.
840
+ const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
836
841
  const seq = useRef(414);
837
842
  const taxId = customer?.taxId ?? "";
838
843
  const taxIdValid = TAX_ID_RE.test(taxId);
@@ -1238,6 +1243,9 @@ export function TplRecord() {
1238
1243
  }, 350);
1239
1244
  });
1240
1245
 
1246
+ // Resolved from the CURRENT invoices every render — the confirm dialog quotes the
1247
+ // total that will actually be billed, including a charge that landed after the press.
1248
+ const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
1241
1249
  const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
1242
1250
  const allMissing = invoices.flatMap(missingMethods);
1243
1251
  const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
@@ -1246,7 +1254,7 @@ export function TplRecord() {
1246
1254
  seq.current += 1;
1247
1255
  const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
1248
1256
  setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
1249
- setConfirmIssue(null);
1257
+ setConfirmIssueKey(null);
1250
1258
  logActivity("credit-card", `Invoice ${ref} issued — ${inv.title}`, { description: formatMoney(invoiceTotal(inv)) });
1251
1259
  };
1252
1260
 
@@ -2124,7 +2132,7 @@ export function TplRecord() {
2124
2132
  gated={customer === null || !taxIdValid}
2125
2133
  onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
2126
2134
  onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
2127
- onIssue={setConfirmIssue}
2135
+ onIssue={setConfirmIssueKey}
2128
2136
  />
2129
2137
  ))}
2130
2138
 
@@ -2585,7 +2593,7 @@ export function TplRecord() {
2585
2593
  </Dialog>
2586
2594
 
2587
2595
  {/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
2588
- <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssue(null); }}>
2596
+ <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssueKey(null); }}>
2589
2597
  <DialogHeader>
2590
2598
  <DialogHeaderTitle>{confirmIssue?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
2591
2599
  </DialogHeader>
@@ -2601,7 +2609,7 @@ export function TplRecord() {
2601
2609
  </Callout>
2602
2610
  </View>
2603
2611
  <DialogFooter>
2604
- <Button title="Cancel" color="secondary" onPress={() => setConfirmIssue(null)} />
2612
+ <Button title="Cancel" color="secondary" onPress={() => setConfirmIssueKey(null)} />
2605
2613
  <Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
2606
2614
  </DialogFooter>
2607
2615
  </Dialog>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "16.0.0",
3
+ "version": "16.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -124,6 +124,7 @@
124
124
  "./menu_button": "./src/menu_button.tsx",
125
125
  "./menu_list_item": "./src/menu_list_item.tsx",
126
126
  "./pressable_highlight": "./src/pressable_highlight.tsx",
127
+ "./pending_commits": "./src/pending_commits.ts",
127
128
  "./pressable_row": "./src/pressable_row.tsx",
128
129
  "./press_door": "./src/press_door.tsx",
129
130
  "./floating_action_bar": "./src/floating_action_bar.tsx",
@@ -151,6 +152,7 @@
151
152
  "./use_screen_size": "./src/use_screen_size.ts",
152
153
  "./use_auto_grow_height": "./src/use_auto_grow_height.ts",
153
154
  "./use_focus_ring": "./src/use_focus_ring.ts",
155
+ "./use_gated_press": "./src/use_gated_press.ts",
154
156
  "./interaction_modality": "./src/interaction_modality.ts",
155
157
  "./inline_focus": "./src/inline_focus.ts",
156
158
  "./use_hover": "./src/use_hover.ts",
package/src/button.tsx CHANGED
@@ -12,11 +12,12 @@ import { Text } from "./text";
12
12
  import { colors } from "./colors";
13
13
  import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
14
14
  import { Icon, IconName } from "./icon";
15
- import { Ref, useCallback } from "react";
15
+ import { Ref } from "react";
16
16
  import { TextColor } from "./text_utils";
17
17
  import { useTooltip, UseTooltipOptions } from "./tooltip";
18
18
  import { useFocusRing, composeHandler } from "./use_focus_ring";
19
19
  import { getButtonIconColor } from "./button_colors";
20
+ import { useGatedPress } from "./use_gated_press";
20
21
  export type ButtonColor = "primary" | "secondary" | "danger" | "muted" | "danger-secondary";
21
22
  export type ButtonIconPosition = "left" | "right";
22
23
 
@@ -60,20 +61,18 @@ export function Button(props: ButtonProps) {
60
61
  accessibilityLabel,
61
62
  } = props;
62
63
 
63
- const disabledOrLoading = disabled || loading;
64
+ // The press waits out an inline commit the same gesture started (pending_commits.ts)
65
+ // and reports busy while it does — so `busy`, not the `loading` prop alone, drives the
66
+ // spinner, aria-busy, and the double-press block.
67
+ const { handlePress, waiting } = useGatedPress<NativeSyntheticEvent<any>>(onPress);
68
+ const busy = loading || waiting;
69
+ const disabledOrLoading = disabled || busy;
64
70
  const tooltipProps = useTooltip(tooltip);
65
71
  const { focusVisible, focusProps } = useFocusRing();
66
72
 
67
- const handlePress = useCallback(
68
- (event: NativeSyntheticEvent<any>) => {
69
- onPress?.(event);
70
- },
71
- [onPress],
72
- );
73
-
74
73
  const children = (
75
74
  <>
76
- {loading ? (
75
+ {busy ? (
77
76
  <ActivityIndicator color={getButtonIconColor(color)} />
78
77
  ) : (
79
78
  <>
@@ -107,7 +106,7 @@ export function Button(props: ButtonProps) {
107
106
  (typeof tooltip === "string" ? tooltip : tooltip?.text) ||
108
107
  undefined
109
108
  }
110
- aria-disabled={disabledOrLoading || undefined} aria-busy={loading || undefined}
109
+ aria-disabled={disabledOrLoading || undefined} aria-busy={busy || undefined}
111
110
  disabled={disabledOrLoading}
112
111
  style={({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
113
112
  const isPrimary = color === "primary" && !disabled;
@@ -4,8 +4,9 @@ import { colors } from "./colors";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
5
  import { type ButtonColor } from "./button";
6
6
  import { getButtonIconColor } from "./button_colors";
7
- import { Ref, useCallback } from "react";
7
+ import { Ref } from "react";
8
8
  import { TooltipSide } from "./tooltip";
9
+ import { useGatedPress } from "./use_gated_press";
9
10
 
10
11
  interface IconButtonBase {
11
12
  ref?: Ref<View>;
@@ -61,14 +62,12 @@ export function IconButton(props: IconButtonProps) {
61
62
  style,
62
63
  } = props;
63
64
 
64
- const handlePress = useCallback(
65
- (event: GestureResponderEvent) => {
66
- onPress?.(event);
67
- },
68
- [onPress],
69
- );
65
+ // The press waits out an inline commit the same gesture started (pending_commits.ts)
66
+ // and reports busy while it does — `busy` drives the spinner + the double-press block.
67
+ const { handlePress, waiting } = useGatedPress<GestureResponderEvent>(onPress);
68
+ const busy = loading || waiting;
70
69
 
71
- const disabledOrLoading = disabled || loading;
70
+ const disabledOrLoading = disabled || busy;
72
71
  const tint =
73
72
  iconColor ??
74
73
  (color === "none" ? colors.zinc[700] : color === "white" ? colors.white : getButtonIconColor(color));
@@ -88,7 +87,7 @@ export function IconButton(props: IconButtonProps) {
88
87
  // without shifting surrounding layouts.
89
88
  hitSlop={size === "lg" ? 0 : size === "sm" ? 8 : 6}
90
89
  >
91
- {loading ? (
90
+ {busy ? (
92
91
  <ActivityIndicator size="small" color={tint} />
93
92
  ) : (
94
93
  <Icon size={size === "sm" ? 14 : size === "lg" ? 20 : 18} name={icon} color={tint} />
@@ -10,6 +10,7 @@ import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./
10
10
  import { fontFamilyRegular, getInputTextStyle, getTextColor, type TextColor } from "./text_utils";
11
11
  import { getInteractionModality } from "./interaction_modality";
12
12
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
13
+ import { trackCommit } from "./pending_commits";
13
14
 
14
15
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
15
16
  * The view box matches it — same height, padding, and a 1px transparent border
@@ -69,7 +70,11 @@ export function useInlineEdit<T>(opts: {
69
70
  setSaving(true);
70
71
  setError(null);
71
72
  try {
72
- await onSave(candidate);
73
+ // Registered so an action pressed in the SAME gesture (the press that caused
74
+ // this blur) waits for the write instead of reading the pre-edit record —
75
+ // see pending_commits.ts. The IIFE starts `onSave` synchronously, so the
76
+ // write still leaves on mousedown, and turns a sync throw into a rejection.
77
+ await trackCommit((async () => onSave(candidate))());
73
78
  setEditing(false);
74
79
  } catch (e) {
75
80
  // Stay in edit mode so the entry isn't lost — show the error, re-arm.
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { pendingCommits, trackCommit } from "./pending_commits";
3
+
4
+ /** A field write that lands in `stored` after `ms`, like a save round trip. */
5
+ const commit = (stored: { value: string }, next: string, ms: number) =>
6
+ new Promise<void>((resolve) =>
7
+ setTimeout(() => {
8
+ stored.value = next;
9
+ resolve();
10
+ }, ms),
11
+ );
12
+
13
+ /** Let the registry's own settle callback run before asserting on it. */
14
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
15
+
16
+ describe("pendingCommits", () => {
17
+ it("is null with nothing in flight — a press keeps its synchronous path", () => {
18
+ expect(pendingCommits()).toBeNull();
19
+ });
20
+
21
+ it("holds until a commit still in flight has landed", async () => {
22
+ const stored = { value: "BKG CŨ: " };
23
+ void trackCommit(commit(stored, "BKG CŨ: SGNA52926200", 20));
24
+ const gate = pendingCommits();
25
+ expect(gate).not.toBeNull();
26
+ await gate;
27
+ expect(stored.value).toBe("BKG CŨ: SGNA52926200");
28
+ });
29
+
30
+ it("holds for EVERY commit in flight, not just the first", async () => {
31
+ const landed: string[] = [];
32
+ const land = (name: string, ms: number) =>
33
+ new Promise<void>((resolve) =>
34
+ setTimeout(() => {
35
+ landed.push(name);
36
+ resolve();
37
+ }, ms),
38
+ );
39
+ void trackCommit(land("slow", 30));
40
+ void trackCommit(land("fast", 5));
41
+ await pendingCommits();
42
+ expect([...landed].sort()).toEqual(["fast", "slow"]);
43
+ });
44
+
45
+ it("releases again once the commits have landed", async () => {
46
+ await trackCommit(Promise.resolve());
47
+ await tick();
48
+ expect(pendingCommits()).toBeNull();
49
+ });
50
+
51
+ it("is released by a FAILED commit too — a stuck gate would wedge every action", async () => {
52
+ const failed = trackCommit(Promise.reject(new Error("Save failed")));
53
+ // The caller owns the error: the inline editor keeps the edit and shows it.
54
+ await expect(failed).rejects.toThrow("Save failed");
55
+ await expect(pendingCommits() ?? Promise.resolve()).resolves.toBeUndefined();
56
+ await tick();
57
+ expect(pendingCommits()).toBeNull();
58
+ });
59
+
60
+ it("ignores a commit started AFTER the gate was taken — it holds the press for the edit that caused it, not later work", async () => {
61
+ const stored = { value: "before" };
62
+ void trackCommit(commit(stored, "first", 5));
63
+ const gate = pendingCommits();
64
+ void trackCommit(commit(stored, "later", 40));
65
+ await gate;
66
+ expect(stored.value).toBe("first");
67
+ });
68
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Inline commits that are still writing, app-wide.
3
+ *
4
+ * An inline editor commits on BLUR, so pressing an action while a field is focused
5
+ * starts TWO things in ONE gesture: the field's write (the blur, on mousedown) and
6
+ * the action's handler (on mouseup). A handler that re-reads the record — to print a
7
+ * document, mint an invoice, copy the row — otherwise overtakes the still-in-flight
8
+ * write and acts on the PRE-EDIT value. It fails silently: the screen shows the new
9
+ * value and the output carries the old one.
10
+ *
11
+ * `useInlineEdit` registers every commit here and `Button`/`IconButton` hold their
12
+ * press for the ones already in flight, so the press lands AFTER the edit that caused
13
+ * it. There is nothing for an app to wire up, and nothing it can forget.
14
+ */
15
+
16
+ const inFlight = new Set<Promise<void>>();
17
+
18
+ /**
19
+ * Register a commit. Returns it UNCHANGED — the caller keeps its own result and error
20
+ * handling; the gate only needs to know when the write SETTLED, so a failed commit
21
+ * releases it just like a successful one (the field surfaces its own error).
22
+ */
23
+ export function trackCommit<T>(commit: Promise<T>): Promise<T> {
24
+ const settled = commit.then(
25
+ () => undefined,
26
+ () => undefined,
27
+ );
28
+ inFlight.add(settled);
29
+ void settled.then(() => {
30
+ inFlight.delete(settled);
31
+ });
32
+ return commit;
33
+ }
34
+
35
+ /**
36
+ * The commits in flight RIGHT NOW, or `null` when there are none — a caller takes its
37
+ * synchronous path on `null`, so a press with nothing pending behaves exactly as it
38
+ * always did. Commits started after this call are deliberately NOT awaited: the gate
39
+ * holds a press for the edit that caused it, never for later work.
40
+ */
41
+ export function pendingCommits(): Promise<void> | null {
42
+ if (inFlight.size === 0) return null;
43
+ const snapshot = Array.from(inFlight);
44
+ return Promise.all(snapshot).then(() => undefined);
45
+ }
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { pendingCommits } from "./pending_commits";
3
+
4
+ /**
5
+ * Makes an action's press land AFTER any inline commit the same gesture started —
6
+ * see {@link pendingCommits} for the race it closes.
7
+ *
8
+ * With nothing in flight the press dispatches SYNCHRONOUSLY, exactly as before, so the
9
+ * gate is invisible outside the racing case. While waiting the action reports busy, so
10
+ * the gesture reads as "working" rather than "ignored" and cannot double-fire.
11
+ */
12
+ export function useGatedPress<E>(onPress?: (event: E) => void) {
13
+ const [waiting, setWaiting] = useState(false);
14
+ // Re-armed on mount: a StrictMode remount must not leave the ref stuck at false.
15
+ const mounted = useRef(true);
16
+ useEffect(() => {
17
+ mounted.current = true;
18
+ return () => {
19
+ mounted.current = false;
20
+ };
21
+ }, []);
22
+
23
+ const handlePress = useCallback(
24
+ (event: E) => {
25
+ const pending = pendingCommits();
26
+ if (!pending) {
27
+ onPress?.(event);
28
+ return;
29
+ }
30
+ setWaiting(true);
31
+ void pending.then(() => {
32
+ if (!mounted.current) return;
33
+ setWaiting(false);
34
+ onPress?.(event);
35
+ });
36
+ },
37
+ [onPress],
38
+ );
39
+
40
+ return { handlePress, waiting };
41
+ }