@jini-ai/ui 0.4.3 → 0.4.5
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/CHANGELOG.md +11 -0
- package/README.md +20 -0
- package/dist/core/theme/default.d.ts +4 -0
- package/dist/core/theme/default.js +11 -0
- package/dist/core/theme/stylesheet.d.ts +8 -0
- package/dist/core/theme/stylesheet.js +14 -0
- package/dist/core/theme/types.d.ts +39 -0
- package/dist/core/theme/types.js +2 -0
- package/dist/core/theme/validation.d.ts +15 -0
- package/dist/core/theme/validation.js +55 -0
- package/dist/features/admin-widgets/components/Select/Select.d.ts +11 -3
- package/dist/features/admin-widgets/components/Select/Select.hooks.d.ts +8 -13
- package/dist/features/admin-widgets/components/Select/Select.hooks.js +37 -215
- package/dist/features/admin-widgets/components/Select/Select.js +4 -3
- package/dist/features/appearance/constants.js +1 -1
- package/dist/features/connectors/rules.d.ts +1 -6
- package/dist/features/connectors/types.d.ts +1 -6
- package/dist/features/connectors/types.js +1 -6
- package/dist/features/execution/http-adapter.d.ts +111 -0
- package/dist/features/execution/http-adapter.js +160 -0
- package/dist/features/execution/index.d.ts +4 -0
- package/dist/features/execution/index.js +2 -0
- package/dist/features/execution/react/hooks/use-execution-credential.hooks.d.ts +232 -0
- package/dist/features/execution/react/hooks/use-execution-credential.hooks.js +217 -0
- package/dist/features/html-editor/canvas-style.d.ts +1 -1
- package/dist/features/html-editor/canvas-style.js +2 -2
- package/dist/features/i18n/dictionaries/settings-dialog.ar.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.bn.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.de.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.en.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.es.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.fa.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.fr.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.hi.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.hu.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.id.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.it.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ja.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ko.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.pl.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.pt-BR.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ru.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.th.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.tr.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.uk.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ur.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.zh-CN.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.zh-TW.js +2 -0
- package/dist/features/i18n/dictionary-translator.d.ts +5 -2
- package/dist/features/i18n/dictionary-translator.js +1 -1
- package/dist/features/i18n/index.d.ts +1 -1
- package/dist/features/i18n/index.js +1 -1
- package/dist/features/i18n/template-i18n.d.ts +11 -0
- package/dist/features/i18n/template-i18n.js +12 -0
- package/dist/features/interactive-ui/interactive-ui.css +1 -1
- package/dist/features/interactive-ui/providers/recharts/bar-chart.js +3 -2
- package/dist/features/interactive-ui/providers/recharts/chart-tokens.d.ts +43 -0
- package/dist/features/interactive-ui/providers/recharts/chart-tokens.js +44 -0
- package/dist/features/interactive-ui/providers/recharts/line-chart.js +3 -2
- package/dist/features/interactive-ui/providers/recharts/pie-chart.js +2 -2
- package/dist/features/lexical-rich-text-editor/deserialize.d.ts +0 -5
- package/dist/features/lexical-rich-text-editor/deserialize.js +0 -5
- package/dist/features/lexical-rich-text-editor/mention-node.d.ts +5 -17
- package/dist/features/lexical-rich-text-editor/mention-node.js +5 -17
- package/dist/features/lexical-rich-text-editor/mention-parser.d.ts +2 -4
- package/dist/features/lexical-rich-text-editor/serialize.d.ts +0 -4
- package/dist/features/lexical-rich-text-editor/serialize.js +0 -4
- package/dist/features/lexical-rich-text-editor/types.d.ts +3 -13
- package/dist/features/lexical-rich-text-editor/types.js +3 -13
- package/dist/features/mcp-ui/early-message-buffer.d.ts +1 -2
- package/dist/features/mcp-ui/early-message-buffer.js +1 -2
- package/dist/features/mcp-ui/escape.d.ts +4 -0
- package/dist/features/mcp-ui/escape.js +2 -9
- package/dist/features/mcp-ui/index.d.ts +1 -0
- package/dist/features/mcp-ui/index.js +1 -0
- package/dist/features/mcp-ui/protocol.d.ts +2 -5
- package/dist/features/mcp-ui/protocol.js +2 -5
- package/dist/features/mcp-ui/resource.d.ts +47 -7
- package/dist/features/mcp-ui/resource.js +5 -8
- package/dist/features/mcp-ui/secret-card.d.ts +115 -0
- package/dist/features/mcp-ui/secret-card.js +137 -0
- package/dist/features/mcp-ui/surfaces/confirmation.js +5 -0
- package/dist/features/mcp-ui/surfaces/fields.d.ts +5 -2
- package/dist/features/mcp-ui/surfaces/fields.js +3 -1
- package/dist/features/mcp-ui/surfaces/form.d.ts +4 -1
- package/dist/features/mcp-ui/surfaces/form.js +65 -3
- package/dist/features/mcp-ui/surfaces/outcome.d.ts +2 -0
- package/dist/features/mcp-ui/surfaces/text-input.d.ts +14 -6
- package/dist/features/mcp-ui/surfaces/text-input.js +16 -2
- package/dist/features/mcp-ui/surfaces/tokens.js +1 -1
- package/dist/features/memory/react/components/MemoryConnectedPanel.d.ts +1 -1
- package/dist/features/memory/react/components/MemoryConnectedPanel.js +0 -1
- package/dist/features/panel-kit/helpers/format-timestamp.d.ts +5 -3
- package/dist/features/panel-kit/helpers/format-timestamp.js +13 -4
- package/dist/features/panel-kit/helpers/permissions.js +2 -0
- package/dist/features/panel-kit/helpers/resolve-active-tab-id.js +2 -0
- package/dist/features/panel-kit/helpers/standing-draft-local-backup.d.ts +51 -0
- package/dist/features/panel-kit/helpers/standing-draft-local-backup.js +77 -0
- package/dist/features/panel-kit/helpers/standing-draft-recovery-message.d.ts +8 -0
- package/dist/features/panel-kit/helpers/standing-draft-recovery-message.js +10 -0
- package/dist/features/panel-kit/hooks/use-async-action.hooks.d.ts +1 -0
- package/dist/features/panel-kit/hooks/use-dirty-guard.hooks.d.ts +1 -0
- package/dist/features/panel-kit/hooks/use-dirty-guard.hooks.js +1 -0
- package/dist/features/panel-kit/hooks/use-serial-writes.hooks.d.ts +8 -0
- package/dist/features/panel-kit/hooks/use-serial-writes.hooks.js +3 -0
- package/dist/features/panel-kit/hooks/use-settings-slice.hooks.d.ts +211 -0
- package/dist/features/panel-kit/hooks/use-settings-slice.hooks.js +382 -0
- package/dist/features/panel-kit/hooks/use-standing-draft-autosave.hooks.d.ts +179 -0
- package/dist/features/panel-kit/hooks/use-standing-draft-autosave.hooks.js +300 -0
- package/dist/features/panel-kit/index.d.ts +7 -1
- package/dist/features/panel-kit/index.js +5 -1
- package/dist/features/resource-dashboard/types.d.ts +8 -41
- package/dist/features/resource-dashboard/types.js +3 -26
- package/dist/features/tab-strip/constants.d.ts +3 -4
- package/dist/features/tab-strip/constants.js +3 -4
- package/dist/features/tab-strip/dependencies.d.ts +1 -6
- package/dist/features/tab-strip/dependencies.js +1 -6
- package/dist/features/tab-strip/react/components/TabBar.d.ts +33 -1
- package/dist/features/tab-strip/react/components/TabBar.hooks.d.ts +57 -5
- package/dist/features/tab-strip/react/components/TabBar.hooks.js +63 -5
- package/dist/features/tab-strip/react/components/TabBar.js +12 -1
- package/dist/features/tab-strip/rules.d.ts +4 -17
- package/dist/features/tab-strip/rules.js +4 -17
- package/dist/features/tab-strip/types.d.ts +2 -13
- package/dist/icon-name.d.ts +1 -1
- package/dist/icon-name.js +1 -0
- package/dist/index.d.ts +42 -417
- package/dist/index.js +42 -430
- package/dist/react/components/CustomSelect.d.ts +6 -2
- package/dist/react/components/CustomSelect.js +20 -91
- package/dist/react/components/Icon.d.ts +6 -0
- package/dist/react/components/Icon.js +12 -0
- package/dist/react/components/select-popover.d.ts +106 -0
- package/dist/react/components/select-popover.js +210 -0
- package/dist/renderers/sandbox-scripts.d.ts +29 -0
- package/dist/renderers/sandbox-scripts.js +127 -0
- package/dist/renderers/sandboxed-document.js +3 -86
- package/dist/renderers/srcdoc/build.d.ts +10 -17
- package/dist/renderers/srcdoc/build.js +31 -234
- package/dist/styles/variables.css +30 -0
- package/dist/theme/apply.d.ts +1 -1
- package/dist/theme/apply.js +2 -2
- package/dist/theme/color-scheme.d.ts +1 -1
- package/dist/theme/default.d.ts +1 -3
- package/dist/theme/default.js +2 -10
- package/dist/theme/index.d.ts +3 -3
- package/dist/theme/index.js +2 -2
- package/dist/theme/stylesheet.d.ts +1 -7
- package/dist/theme/stylesheet.js +2 -13
- package/dist/theme/types.d.ts +1 -38
- package/dist/theme/types.js +2 -1
- package/dist/theme/validation.d.ts +1 -14
- package/dist/theme/validation.js +2 -54
- package/dist/utils/endpoint-policy.d.ts +21 -39
- package/dist/utils/endpoint-policy.js +19 -107
- package/dist/utils/file-system-errors.d.ts +1 -2
- package/dist/utils/file-system-errors.js +1 -2
- package/dist/utils/ime-composing.d.ts +0 -3
- package/dist/utils/ime-composing.js +0 -3
- package/dist/utils/platform.d.ts +1 -4
- package/dist/utils/platform.js +1 -4
- package/dist/utils/smooth-scroll-to-top.d.ts +1 -3
- package/dist/utils/smooth-scroll-to-top.js +3 -13
- package/dist/utils/timezone.js +1 -5
- package/dist/utils/uuid.d.ts +0 -3
- package/dist/utils/uuid.js +0 -3
- package/dist/utils/visual-stability.d.ts +1 -3
- package/dist/utils/visual-stability.js +1 -3
- package/package.json +74 -15
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useSerialWrites } from "./use-serial-writes.hooks.js";
|
|
3
|
+
import { useSettlementGeneration } from "./use-settlement-generation.hooks.js";
|
|
4
|
+
/** Debounce for text fields: tabs are controlled and fire on every keystroke,
|
|
5
|
+
* and each ledger write is an append-only revision. Without this, typing into
|
|
6
|
+
* a textarea would write one revision per character. */
|
|
7
|
+
export const SAVE_DEBOUNCE_MS = 600;
|
|
8
|
+
/**
|
|
9
|
+
* Runs one save-chain link: calls `runSave`, then paints the ticket-owning outcome onto
|
|
10
|
+
* `saveState` — unless a newer save has since taken the ticket (`currentTicket()` no longer matches
|
|
11
|
+
* `ticket`), or the operator has queued another edit behind this one (`hasUnsavedEdits()`). Pulled
|
|
12
|
+
* out of `onChange`'s debounce-timer closure (2026-08-06, complexity pass) — it was the deepest
|
|
13
|
+
* nesting in this file, three closures in (`onChange` > the `setTimeout` callback > the
|
|
14
|
+
* `saveChain.current.then(async () => …)` link) — so the ticket/staleness handling is directly
|
|
15
|
+
* assertable against a fake `runSave`, without a real timer or save chain.
|
|
16
|
+
*
|
|
17
|
+
* The ref reads are passed as thunks rather than plain booleans specifically so this keeps the
|
|
18
|
+
* original "read at run time, not schedule time" contract this whole file's header calls out:
|
|
19
|
+
* `deps.currentTicket()`/`deps.hasUnsavedEdits()` are called from inside this function, at the same
|
|
20
|
+
* points the inline version read `saveTicket.current`/`hasUnsavedEdits.current` directly, so a
|
|
21
|
+
* ticket or edit that changes while `runSave` is in flight is still seen correctly.
|
|
22
|
+
*
|
|
23
|
+
* @param ticket - This link's own ticket, taken by the caller when the debounce timer fired.
|
|
24
|
+
*/
|
|
25
|
+
export async function commitQueuedSave({ ticket, deps }, _options = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const written = await deps.runSave();
|
|
28
|
+
if (deps.currentTicket() !== ticket)
|
|
29
|
+
return;
|
|
30
|
+
// A newer edit is already queued behind this one, so "Saved" would be a claim about state
|
|
31
|
+
// that is not saved. Stay in "saving".
|
|
32
|
+
if (deps.hasUnsavedEdits())
|
|
33
|
+
return;
|
|
34
|
+
deps.setSaveState(written.length > 0 ? { status: "saved" } : { status: "idle" });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (deps.currentTicket() !== ticket)
|
|
38
|
+
return;
|
|
39
|
+
deps.setSaveState({
|
|
40
|
+
status: "error",
|
|
41
|
+
message: error instanceof Error ? error.message : String(error),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a background `refresh()` reload may still land, given what is true about the slice's
|
|
47
|
+
* uncommitted-work state at the moment of the check.
|
|
48
|
+
*
|
|
49
|
+
* Pulled out of `refresh` (2026-08-06, complexity pass, third pass): the same four-flag shape is
|
|
50
|
+
* checked TWICE inside `refresh` — once before the `load()` await (where `mounted`/`commitsUnchanged`
|
|
51
|
+
* are trivially true, since nothing could have unmounted or committed a save before the function has
|
|
52
|
+
* even started awaiting), once after (where all four are live) — and collapsing the check into one
|
|
53
|
+
* named function removes the duplicated branch count rather than moving it. This does not change
|
|
54
|
+
* WHEN any ref is read: every call site below still reads `timer.current`/`hasUnsavedEdits.current`/
|
|
55
|
+
* `mounted.current`/`commits.current` at exactly the point in `refresh`'s own body the inline version
|
|
56
|
+
* did, in the same tick — this function only names the boolean combination those reads produce. See
|
|
57
|
+
* `refresh`'s own doc for why a re-check after the await is necessary at all.
|
|
58
|
+
*/
|
|
59
|
+
export function canAcceptRefresh(state, _options = {}) {
|
|
60
|
+
return !state.timerPending && !state.hasUnsavedEdits && state.mounted && state.commitsUnchanged;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* @complexity O(1) per edit — one debounce timer reset plus one link appended
|
|
64
|
+
* to the save chain.
|
|
65
|
+
*
|
|
66
|
+
* @complexityExemption (2026-08-06, complexity pass, third pass; bar raised to ≤9/≤9 same day,
|
|
67
|
+
* exemption reconfirmed against the new bar) **Score: this hook's own lexical scope is 1
|
|
68
|
+
* cyclomatic / ~0 cognitive under ESLint — every closure inside it is independently ≤9/≤9 too
|
|
69
|
+
* (`onChange`'s debounce callback is 2/1, the unmount-flush cleanup is 3/2, `refresh` is 5/4 as of
|
|
70
|
+
* this pass — see below). What is exempted here is a DIFFERENT, unmeasurable-by-me number: the
|
|
71
|
+
* dispatch brief's owner-tool score of 17/23, which rolls every nested closure's branches into the
|
|
72
|
+
* hook's total. Bar: ≤9/≤9 on whichever view is scored — ESLint's view already clears it; the
|
|
73
|
+
* owner-tool aggregate does not, and I have no local tool that reproduces that aggregate to
|
|
74
|
+
* re-measure against the new bar.**
|
|
75
|
+
*
|
|
76
|
+
* Same shape as `use-assistant-chats.hooks.ts`'s `useAssistantChats` exemption — see that one for
|
|
77
|
+
* the full reasoning. `commitQueuedSave` (first pass) and `canAcceptRefresh` (this pass, pulled out
|
|
78
|
+
* of `refresh`'s two duplicated guard checks — see its own doc) are the two pieces of this hook's
|
|
79
|
+
* logic that genuinely fit a top-level pure-function shape (0–4 plain params, no ref reads);
|
|
80
|
+
* `refresh` itself dropped from 9/5 to 5/4 as a result, its own per-function score real progress,
|
|
81
|
+
* not exempted. What remains (`runSave`/`onChange`/the unmount cleanup/the rest of `refresh`) each
|
|
82
|
+
* reads or writes 3–6 of this hook's own refs (`io`, `persisted`, `latest`, `timer`,
|
|
83
|
+
* `saveTicket`, `hasUnsavedEdits`, `commits`, `mounted`) plus the shared `writes` lane, and several of
|
|
84
|
+
* them call each other
|
|
85
|
+
* (`onChange` schedules a link that calls `runSave`; the unmount cleanup calls `runSave` directly),
|
|
86
|
+
* so none of them can become a top-level function without threading that whole ref set through as a
|
|
87
|
+
* parameter object. This file's own header states the reason that is dangerous here specifically:
|
|
88
|
+
* "a value read at schedule time is a claim about the store that may already be false by the time
|
|
89
|
+
* the code using it runs," and every ref above exists because an earlier version of exactly this
|
|
90
|
+
* logic got that ordering wrong (four distinct data-loss bugs, per the header). A previous pass
|
|
91
|
+
* already tried this kind of extraction on this file and the total was unchanged, 25 → 25 — the
|
|
92
|
+
* honest reading of that result is that the remaining closures do not have a top-level-shaped cut
|
|
93
|
+
* available at the risk level a time-boxed pass allows, not that the attempt was done wrong.
|
|
94
|
+
* Declined for the remainder; flagged to the coordinator. See this session's report for the full
|
|
95
|
+
* reasoning.
|
|
96
|
+
*/
|
|
97
|
+
export function useSettingsSlice(options, _optional = {}) {
|
|
98
|
+
const { defaultValue } = options;
|
|
99
|
+
const [value, setValue] = useState(null);
|
|
100
|
+
const [loadError, setLoadError] = useState(null);
|
|
101
|
+
const [saveState, setSaveState] = useState({ status: "idle" });
|
|
102
|
+
/** Guards `refresh`'s own `setValue`/`persisted`/`latest` write against a second background
|
|
103
|
+
* refresh started after it: `canAcceptRefresh`'s `commitsUnchanged` check cannot tell two
|
|
104
|
+
* refreshes apart when no save happens between them (both sample the same `commits.current`),
|
|
105
|
+
* so without this an older refresh call that happens to SETTLE last could still land over a
|
|
106
|
+
* newer one's result (S2, plan-components.md 2026-09-20; two rapid SSE frames from another
|
|
107
|
+
* tab's writes is the trigger). Same guard `use-admin-execution-credential.hooks.ts`'s `refresh`
|
|
108
|
+
* and `use-admin-locale.hooks.ts`'s `fetchLocale` already use for the identical shape. */
|
|
109
|
+
const refreshSettlement = useSettlementGeneration();
|
|
110
|
+
/** `load`/`save` are usually inline arrows at the call site, so a new
|
|
111
|
+
* identity arrives on every render. Holding them in a ref keeps the effect
|
|
112
|
+
* and callback below from re-running (and re-loading, and dropping pending
|
|
113
|
+
* timers) once per render. */
|
|
114
|
+
const io = useRef(options);
|
|
115
|
+
io.current = options;
|
|
116
|
+
/** Last value known to be persisted — the diff base handed to `save`. */
|
|
117
|
+
const persisted = useRef(defaultValue);
|
|
118
|
+
/** The newest value the operator has produced, saved or not. A queued save
|
|
119
|
+
* writes THIS rather than whatever was current when it was scheduled, so a
|
|
120
|
+
* save that waited behind another still persists the latest state instead
|
|
121
|
+
* of resurrecting an intermediate one. */
|
|
122
|
+
const latest = useRef(defaultValue);
|
|
123
|
+
const timer = useRef(null);
|
|
124
|
+
/**
|
|
125
|
+
* Saves run strictly one at a time, queued through this lane.
|
|
126
|
+
*
|
|
127
|
+
* The debounce alone does NOT prevent overlap: it only cancels a save that
|
|
128
|
+
* has not STARTED. Once one is in flight, the next edit schedules a fresh
|
|
129
|
+
* timer that can fire while the first is still running, and then two saves
|
|
130
|
+
* race — their per-key writes interleave (so the ledger can end on the older
|
|
131
|
+
* value), and both diff against the same `persisted` base, which the slower
|
|
132
|
+
* one then overwrites on completion. That leaves the diff base claiming a
|
|
133
|
+
* value was persisted that never was, so the next edit skips writing the
|
|
134
|
+
* fields it thinks are already saved.
|
|
135
|
+
*/
|
|
136
|
+
const writes = useSerialWrites();
|
|
137
|
+
/** Monotonic ticket, so only the newest save may write the status
|
|
138
|
+
* indicator. Without it a slow earlier save resolving last would paint
|
|
139
|
+
* "Saved" over a newer save's error, or vice versa. */
|
|
140
|
+
const saveTicket = useRef(0);
|
|
141
|
+
/**
|
|
142
|
+
* True whenever `latest` has moved ahead of `persisted` — i.e. the operator
|
|
143
|
+
* has edited something no save has committed yet.
|
|
144
|
+
*
|
|
145
|
+
* The ticket alone does NOT cover this. A ticket is only taken when a
|
|
146
|
+
* DEBOUNCE TIMER FIRES, so an edit made while an earlier save is still in
|
|
147
|
+
* flight has no ticket yet: the in-flight save still owns the newest one,
|
|
148
|
+
* completes, and paints "Saved" over changes that are not saved at all. If
|
|
149
|
+
* the operator then navigates away, unmount clears the pending timer and
|
|
150
|
+
* those edits are gone — with the UI's last word having been "Saved".
|
|
151
|
+
*/
|
|
152
|
+
const hasUnsavedEdits = useRef(false);
|
|
153
|
+
/**
|
|
154
|
+
* Bumped every time a save commits and advances `persisted`.
|
|
155
|
+
*
|
|
156
|
+
* `refresh` samples it before its `load()` round trip and discards the
|
|
157
|
+
* response if it moved. The two flag checks cannot cover this on their own:
|
|
158
|
+
* `timer` and `hasUnsavedEdits` describe work that is still OUTSTANDING, and
|
|
159
|
+
* a save that both started and finished inside the reload's await window
|
|
160
|
+
* clears them, so a response that predates that write passes every check.
|
|
161
|
+
* Letting it through does not merely repaint a stale value — it installs it
|
|
162
|
+
* as the diff base, so the next edit diffs against a value the store never
|
|
163
|
+
* held and skips writing the fields that differ.
|
|
164
|
+
*/
|
|
165
|
+
const commits = useRef(0);
|
|
166
|
+
/** Guards every post-await `setState` in {@link refresh}, which — unlike the
|
|
167
|
+
* mount load — can be invoked at any time by an external publisher. */
|
|
168
|
+
const mounted = useRef(true);
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
mounted.current = true;
|
|
171
|
+
return () => {
|
|
172
|
+
mounted.current = false;
|
|
173
|
+
};
|
|
174
|
+
}, []);
|
|
175
|
+
// `defaultValue` is a module-level constant at every call site; re-running
|
|
176
|
+
// this on a new identity would re-issue the load and clobber live edits.
|
|
177
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `defaultValue` is a module-level constant; re-running on a new identity would re-issue the load and clobber live edits.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
let alive = true;
|
|
180
|
+
io.current
|
|
181
|
+
.load({}, {})
|
|
182
|
+
.then((loaded) => {
|
|
183
|
+
if (!alive)
|
|
184
|
+
return;
|
|
185
|
+
// `persisted` always takes the raw load, same as `refresh()` — it is the diff base the next
|
|
186
|
+
// save diffs against, and the server's real state is a strictly better base than the
|
|
187
|
+
// never-actually-persisted `defaultValue` this ref started at, whether or not the operator
|
|
188
|
+
// has since edited.
|
|
189
|
+
persisted.current = loaded;
|
|
190
|
+
// Guards the same hazard `refresh()` guards via `canAcceptRefresh`'s `hasUnsavedEdits` check,
|
|
191
|
+
// on the OTHER load path: `onChange` has no documented precondition requiring `value` to be
|
|
192
|
+
// non-null first, so nothing stops an edit from landing before this initial `load()` settles.
|
|
193
|
+
// Without this check, that edit is silently overwritten the instant the response arrives —
|
|
194
|
+
// the same class of data loss this file's four prior audited bugs all belong to.
|
|
195
|
+
if (hasUnsavedEdits.current)
|
|
196
|
+
return;
|
|
197
|
+
latest.current = loaded;
|
|
198
|
+
setValue(loaded);
|
|
199
|
+
})
|
|
200
|
+
.catch((error) => {
|
|
201
|
+
if (!alive)
|
|
202
|
+
return;
|
|
203
|
+
setLoadError(error instanceof Error ? error.message : String(error));
|
|
204
|
+
if (hasUnsavedEdits.current)
|
|
205
|
+
return;
|
|
206
|
+
setValue(defaultValue);
|
|
207
|
+
});
|
|
208
|
+
return () => {
|
|
209
|
+
alive = false;
|
|
210
|
+
};
|
|
211
|
+
}, []);
|
|
212
|
+
/**
|
|
213
|
+
* The only place a save is ever issued.
|
|
214
|
+
*
|
|
215
|
+
* Both the target and the diff base are read at RUN time, never at schedule
|
|
216
|
+
* time. By the time a chained link runs, an earlier save may already have
|
|
217
|
+
* advanced `persisted` and the operator may have edited further, so anything
|
|
218
|
+
* captured when the link was queued is potentially a lie about the store.
|
|
219
|
+
*
|
|
220
|
+
* This exists as one function rather than two call sites because the second
|
|
221
|
+
* call site is exactly where that invariant was lost: the unmount flush used
|
|
222
|
+
* to snapshot both values as it queued itself behind an in-flight save, so
|
|
223
|
+
* edit A→B, revert B→A, unmount produced `save(A, A)` — an empty diff — and
|
|
224
|
+
* the revert was dropped while the store kept B. Sharing the runner makes the
|
|
225
|
+
* rule structural instead of something each caller has to remember.
|
|
226
|
+
*/
|
|
227
|
+
const runSave = useCallback(async () => {
|
|
228
|
+
const target = latest.current;
|
|
229
|
+
const written = await io.current.save({ next: target, previous: persisted.current }, {});
|
|
230
|
+
// `persisted` is deliberately advanced only here, after a resolved save, so
|
|
231
|
+
// a rejection leaves the base alone and the next save re-attempts the
|
|
232
|
+
// fields this one could not write.
|
|
233
|
+
persisted.current = target;
|
|
234
|
+
commits.current += 1;
|
|
235
|
+
// Only clear the flag if nothing was edited WHILE this save ran.
|
|
236
|
+
if (latest.current === target)
|
|
237
|
+
hasUnsavedEdits.current = false;
|
|
238
|
+
// Notify same-tab siblings this slice has no direct connection to — same rationale
|
|
239
|
+
// `settings-refresh-bus.ts`'s own doc comment gives for `AssistantDock`: this slice and
|
|
240
|
+
// whatever else reads this namespace (e.g. `useAdminLocale()`, read by ~30 screens outside the
|
|
241
|
+
// Settings dialog for `core.language`) are unrelated subtrees under `App.tsx` with no shared
|
|
242
|
+
// state. Without this, a language switch only reached OTHER tabs via SSE — the tab that made
|
|
243
|
+
// the change never re-read its own write, so it looked "stuck" until a hard reload. Scoped to
|
|
244
|
+
// this slice's own declared `namespaces`, so an unrelated slice's save doesn't trigger a
|
|
245
|
+
// pointless refetch elsewhere; a slice that never declared `namespaces` publishes nothing,
|
|
246
|
+
// matching that it never subscribed to anything either.
|
|
247
|
+
if (io.current.namespaces && io.current.namespaces.length > 0) {
|
|
248
|
+
io.current.refreshPort.publish({ namespaces: io.current.namespaces }, {});
|
|
249
|
+
}
|
|
250
|
+
return written;
|
|
251
|
+
}, []);
|
|
252
|
+
// On unmount, don't just drop a debounced edit — run it. Cancelling the
|
|
253
|
+
// timer silently discards whatever the operator typed in the last 600ms.
|
|
254
|
+
useEffect(() => () => {
|
|
255
|
+
if (!timer.current)
|
|
256
|
+
return;
|
|
257
|
+
clearTimeout(timer.current);
|
|
258
|
+
if (!hasUnsavedEdits.current)
|
|
259
|
+
return;
|
|
260
|
+
// Fire-and-forget: the component is going away, so there is no status
|
|
261
|
+
// left to paint. The write itself still has to happen — through the same
|
|
262
|
+
// lane as the debounce path, so it diffs against the base that is
|
|
263
|
+
// current when it RUNS, not the one that was current when it was queued.
|
|
264
|
+
void writes.run({ task: () => runSave().then(() => { }, () => { }) });
|
|
265
|
+
}, [runSave, writes]);
|
|
266
|
+
const onChange = useCallback((next) => {
|
|
267
|
+
setValue(next);
|
|
268
|
+
latest.current = next;
|
|
269
|
+
hasUnsavedEdits.current = true;
|
|
270
|
+
if (timer.current)
|
|
271
|
+
clearTimeout(timer.current);
|
|
272
|
+
timer.current = setTimeout(() => {
|
|
273
|
+
// Cleared as the timer FIRES, so `timer.current` means "a debounce is still pending" rather
|
|
274
|
+
// than "one was ever scheduled". The unmount flush below already pairs it with
|
|
275
|
+
// `hasUnsavedEdits`, so it tolerated a stale handle; `refresh` reads it alone as the
|
|
276
|
+
// "operator has work in flight" signal, and a handle that never cleared would refuse every
|
|
277
|
+
// external refresh for the rest of the component's life after the first keystroke.
|
|
278
|
+
timer.current = null;
|
|
279
|
+
const ticket = ++saveTicket.current;
|
|
280
|
+
setSaveState({ status: "saving" });
|
|
281
|
+
void writes.run({ task: () => commitQueuedSave({ ticket, deps: {
|
|
282
|
+
runSave,
|
|
283
|
+
currentTicket: () => saveTicket.current,
|
|
284
|
+
hasUnsavedEdits: () => hasUnsavedEdits.current,
|
|
285
|
+
setSaveState,
|
|
286
|
+
} }, {}) });
|
|
287
|
+
}, SAVE_DEBOUNCE_MS);
|
|
288
|
+
}, [runSave, writes]);
|
|
289
|
+
/**
|
|
290
|
+
* Re-reads the persisted value on an external notification.
|
|
291
|
+
*
|
|
292
|
+
* **Refuses whenever the operator has uncommitted work**, rather than merging. Three states count
|
|
293
|
+
* as uncommitted: a pending debounce timer, `hasUnsavedEdits`, and a non-idle save. Overwriting
|
|
294
|
+
* any of them replaces what the operator typed with a value from somewhere else, mid-keystroke —
|
|
295
|
+
* data loss, and the same class of bug the audit already found twice in this file. Refusing costs
|
|
296
|
+
* only a missed update, and the operator's own save re-establishes agreement a moment later.
|
|
297
|
+
*
|
|
298
|
+
* The check runs AGAIN after the await: `load()` is a network round trip, and an edit begun while
|
|
299
|
+
* it was in flight would otherwise be clobbered by a response that predates it.
|
|
300
|
+
*
|
|
301
|
+
* Re-checking the flags is necessary but NOT sufficient, which is why {@link commits} is sampled
|
|
302
|
+
* too. Both flags describe work that is still outstanding, so a save that started and finished
|
|
303
|
+
* entirely within the await window leaves them clear and the stale response sails through — see
|
|
304
|
+
* the counter's own doc for why that is worse than a wrong repaint.
|
|
305
|
+
*
|
|
306
|
+
* A failed reload is swallowed on purpose. This is a background refresh the operator never asked
|
|
307
|
+
* for; surfacing `loadError` here would replace a working panel with an error state because of a
|
|
308
|
+
* transient blip. The mount load still reports its failures.
|
|
309
|
+
*/
|
|
310
|
+
const refresh = useCallback(async () => {
|
|
311
|
+
if (!canAcceptRefresh({
|
|
312
|
+
timerPending: timer.current !== null,
|
|
313
|
+
hasUnsavedEdits: hasUnsavedEdits.current,
|
|
314
|
+
mounted: true,
|
|
315
|
+
commitsUnchanged: true,
|
|
316
|
+
})) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const seenCommits = commits.current;
|
|
320
|
+
const generation = refreshSettlement.next();
|
|
321
|
+
let loaded;
|
|
322
|
+
try {
|
|
323
|
+
loaded = await io.current.load({}, {});
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!refreshSettlement.isCurrent({ generation }) ||
|
|
329
|
+
!canAcceptRefresh({
|
|
330
|
+
timerPending: timer.current !== null,
|
|
331
|
+
hasUnsavedEdits: hasUnsavedEdits.current,
|
|
332
|
+
mounted: mounted.current,
|
|
333
|
+
commitsUnchanged: commits.current === seenCommits,
|
|
334
|
+
})) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// `persisted` tracks what `load`/`save` actually manage, so it takes the RAW reload — a
|
|
338
|
+
// reconciled field like a never-persisted API key has no business in the diff base `save` will
|
|
339
|
+
// next diff against. `latest`/`value` are what the operator sees and edits next, so THEY take
|
|
340
|
+
// the reconciled result.
|
|
341
|
+
const reconciled = io.current.reconcileRefresh ? io.current.reconcileRefresh({ current: latest.current, loaded }, {}) : loaded;
|
|
342
|
+
persisted.current = loaded;
|
|
343
|
+
latest.current = reconciled;
|
|
344
|
+
setValue(reconciled);
|
|
345
|
+
}, []);
|
|
346
|
+
/**
|
|
347
|
+
* Subscribes to out-of-band changes — an assistant run that wrote a setting, or an SSE frame from
|
|
348
|
+
* a write in another tab. `namespaces` narrows which notifications matter; see the option's doc
|
|
349
|
+
* for why omitting it means "refresh on everything" rather than "never".
|
|
350
|
+
*/
|
|
351
|
+
const refreshPort = options.refreshPort;
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
return refreshPort.subscribe({ listener: (scope) => {
|
|
354
|
+
const mine = io.current.namespaces;
|
|
355
|
+
if (scope && mine && !mine.some((ns) => scope.includes(ns)))
|
|
356
|
+
return;
|
|
357
|
+
void refresh();
|
|
358
|
+
} }, {});
|
|
359
|
+
}, [refresh, refreshPort]);
|
|
360
|
+
return { value, loadError, saveState, onChange, refresh };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Collapses several slices' save states into the single one the page chrome
|
|
364
|
+
* renders.
|
|
365
|
+
*
|
|
366
|
+
* Precedence is error > saving > saved > idle, and it is deliberate: an error
|
|
367
|
+
* is the only state the operator must act on, so it must not be hidden by
|
|
368
|
+
* another tab's cheerful "Saved". "Saving" outranks "saved" for the same
|
|
369
|
+
* reason in miniature — while anything is still in flight, claiming everything
|
|
370
|
+
* is saved is false.
|
|
371
|
+
*/
|
|
372
|
+
export function mergeSaveStates({ states }, _options = {}) {
|
|
373
|
+
const failed = states.find((state) => state.status === "error");
|
|
374
|
+
if (failed)
|
|
375
|
+
return failed;
|
|
376
|
+
if (states.some((state) => state.status === "saving"))
|
|
377
|
+
return { status: "saving" };
|
|
378
|
+
if (states.some((state) => state.status === "saved"))
|
|
379
|
+
return { status: "saved" };
|
|
380
|
+
return { status: "idle" };
|
|
381
|
+
}
|
|
382
|
+
//# sourceMappingURL=use-settings-slice.hooks.js.map
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { createStandingDraftLocalBackup } from "../helpers/standing-draft-local-backup.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file Standing-draft autosave (2026-09-06 dispatch, owner's own words: "even if they haven't
|
|
4
|
+
* saved it, temp save it whenever they make a draft... it's just saving to... the database. It
|
|
5
|
+
* should be a standing draft anytime something is updated"). Persists to the server
|
|
6
|
+
* (`server/inbound/admin-http/routes/posts/autosave.ts`, `posts.autosave_json`) so a reload or an
|
|
7
|
+
* accidental exit never loses unsaved edits — browser storage alone was rejected.
|
|
8
|
+
*
|
|
9
|
+
* Feature-agnostic on purpose, matching `use-dirty-guard.hooks.ts`'s own precedent: nothing here
|
|
10
|
+
* imports `features/posts` or `features/pages`, so `PostEditorPort`/`PageEditorPort` each satisfy
|
|
11
|
+
* {@link StandingDraftAutosavePort} structurally (same three method shapes, declared independently
|
|
12
|
+
* per that DI-seam convention) with zero cross-feature import. This is the mechanism that lets one
|
|
13
|
+
* implementation serve both editors with no branch between them.
|
|
14
|
+
*
|
|
15
|
+
* Two things this hook does NOT do, deliberately:
|
|
16
|
+
* - Never auto-applies a recovered draft. {@link StandingDraftAutosaveController.recoverableDraft}
|
|
17
|
+
* is exposed for the caller to render an explicit "restore or discard" banner; silently splicing
|
|
18
|
+
* in a recovered draft is dangerous with two tabs or two operators open on the same entry.
|
|
19
|
+
* - Never compares `recoverableDraft.baseVersion` against the entry's current version itself — the
|
|
20
|
+
* caller (which already has the freshly-loaded record) decides whether to label a recovered draft
|
|
21
|
+
* "outdated" because a real save happened since it was captured.
|
|
22
|
+
*
|
|
23
|
+
* STALE BASIS (added 2026-09-06, fixing silent data loss). `putAutosave` answers `{ applied }`, and
|
|
24
|
+
* `applied: false` means the row's `version` moved under this tab — another tab or operator saved,
|
|
25
|
+
* so every further write against this basis will be refused too (the guard is
|
|
26
|
+
* `host features/post/repo.sqlite.ts`'s `writeAutosave`). That flag used to be thrown
|
|
27
|
+
* away: the hook kept firing refused ticks forever, nothing was ever parked, and a reload produced
|
|
28
|
+
* no recovery banner because there was nothing to recover — an hour of typing could vanish with the
|
|
29
|
+
* operator never told anything. Now a refusal:
|
|
30
|
+
* 1. records {@link StandingDraftStaleBasis} (exposed so the editor can say something true),
|
|
31
|
+
* 2. stops scheduling further writes until the caller supplies a draft on a DIFFERENT `baseVersion`
|
|
32
|
+
* — i.e. until the editor reloads the row; no manual reset call is needed,
|
|
33
|
+
* 3. mirrors the refused text into `lib/standing-draft-local-backup.ts`, the only place it can
|
|
34
|
+
* survive a tab close while the server refuses it, and offers it back through the existing
|
|
35
|
+
* `recoverableDraft` banner on the next mount when the server has nothing parked.
|
|
36
|
+
* The operator's in-memory text is never touched by any of this.
|
|
37
|
+
*
|
|
38
|
+
* The gate and the mirror have DIFFERENT lifetimes on purpose. The gate lifts the instant the
|
|
39
|
+
* caller edits on a fresh basis; the mirror survives until the server has actually accepted a write
|
|
40
|
+
* (or a real Save/discard superseded it), because between those two moments nothing has been
|
|
41
|
+
* persisted anywhere and the mirror is still the only copy of the refused text that outlives the
|
|
42
|
+
* tab. Collapsing the two back into one "clear it all" step reintroduces a window in which a failed
|
|
43
|
+
* request loses the work.
|
|
44
|
+
*/
|
|
45
|
+
/** The fields a standing draft carries — see `PostAutosaveSnapshot`'s own server-side doc for why
|
|
46
|
+
* `status` is deliberately absent (a draft must never change whether a row is live). */
|
|
47
|
+
export type StandingDraftAutosaveInput = {
|
|
48
|
+
bodyFormat: "doc";
|
|
49
|
+
bodyJson: Record<string, unknown>;
|
|
50
|
+
title: string;
|
|
51
|
+
slug: string;
|
|
52
|
+
baseVersion: number;
|
|
53
|
+
} | {
|
|
54
|
+
bodyFormat: "html";
|
|
55
|
+
bodyHtml: string;
|
|
56
|
+
title: string;
|
|
57
|
+
slug: string;
|
|
58
|
+
baseVersion: number;
|
|
59
|
+
};
|
|
60
|
+
export interface StandingDraftAutosaveSnapshot {
|
|
61
|
+
bodyFormat: "doc" | "html";
|
|
62
|
+
bodyJson?: Record<string, unknown>;
|
|
63
|
+
bodyHtml?: string;
|
|
64
|
+
title: string;
|
|
65
|
+
slug: string;
|
|
66
|
+
baseVersion: number;
|
|
67
|
+
savedAt: string;
|
|
68
|
+
savedByPrincipalId: string;
|
|
69
|
+
/** Fresh server timestamp used only for the explicit recovery comparison. */
|
|
70
|
+
serverUpdatedAt?: string | undefined;
|
|
71
|
+
}
|
|
72
|
+
/** What this hook needs from the outside world — see this file's header for why it is declared
|
|
73
|
+
* here rather than imported from either feature's own port. */
|
|
74
|
+
export interface StandingDraftAutosavePort {
|
|
75
|
+
/** Captured before expiry so late failures cannot borrow the next operator's identity. */
|
|
76
|
+
getBackupPrincipalId?: (requiredArgs: Record<string, never>, optionalArgs?: Record<string, never>) => string | null;
|
|
77
|
+
/**
|
|
78
|
+
* `options.keepalive` (2026-09-07) asks for an UNLOAD-SAFE transport, and is set only by the exit
|
|
79
|
+
* flush below. An ordinary `fetch` started from `pagehide` or `visibilitychange`→hidden is
|
|
80
|
+
* cancelled when the browser tears the document down — so the newest edit, the one still sitting
|
|
81
|
+
* in the debounce window, was precisely the one those listeners could not actually rescue.
|
|
82
|
+
*
|
|
83
|
+
* Opt-in rather than always-on because `keepalive` carries a 64 KiB body ceiling an ordinary
|
|
84
|
+
* request does not; an implementation that cannot honour it may ignore it (the parameter is
|
|
85
|
+
* optional), which is exactly the behavior this path had before.
|
|
86
|
+
*/
|
|
87
|
+
putAutosave(required: {
|
|
88
|
+
id: string;
|
|
89
|
+
draft: StandingDraftAutosaveInput;
|
|
90
|
+
}, options?: {
|
|
91
|
+
keepalive?: boolean;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
applied: boolean;
|
|
94
|
+
}>;
|
|
95
|
+
getAutosave(required: {
|
|
96
|
+
id: string;
|
|
97
|
+
}, optional?: Record<string, never>): Promise<{
|
|
98
|
+
autosave: StandingDraftAutosaveSnapshot | null;
|
|
99
|
+
}>;
|
|
100
|
+
discardAutosave(required: {
|
|
101
|
+
id: string;
|
|
102
|
+
}, optional?: Record<string, never>): Promise<{
|
|
103
|
+
ok: boolean;
|
|
104
|
+
}>;
|
|
105
|
+
}
|
|
106
|
+
/** A write the server refused because the row moved on — see this file's header. Carries the
|
|
107
|
+
* operator's refused text itself, not just a flag: it is the copy nothing else on the client has
|
|
108
|
+
* a claim to, and a conflict notice that drops the work would be worse than the silent drop. */
|
|
109
|
+
export interface StandingDraftStaleBasis {
|
|
110
|
+
/** The `baseVersion` the refused draft was built on. The gate lifts when a later
|
|
111
|
+
* `scheduleAutosave` arrives on a different one. */
|
|
112
|
+
baseVersion: number;
|
|
113
|
+
/** Exactly what was refused. */
|
|
114
|
+
draft: StandingDraftAutosaveInput;
|
|
115
|
+
}
|
|
116
|
+
export interface StandingDraftAutosaveController {
|
|
117
|
+
/** Non-null once the mount-time recovery check resolves and finds a parked draft. Stays exactly
|
|
118
|
+
* as read until {@link dismissRecoverable}/{@link clearStandingDraft} clears it — never
|
|
119
|
+
* overwritten by a later `scheduleAutosave` tick from THIS session, since a session that is
|
|
120
|
+
* actively autosaving has, by definition, already accounted for whatever it found on mount. */
|
|
121
|
+
recoverableDraft: StandingDraftAutosaveSnapshot | null;
|
|
122
|
+
/** Non-null once the server has refused a write for this entry, and until the caller edits on a
|
|
123
|
+
* fresh basis (or a real Save/discard runs). While it is set, `scheduleAutosave` deliberately
|
|
124
|
+
* sends nothing — every such write would be refused. The editor should tell the operator that
|
|
125
|
+
* another save happened, that reloading is what resumes autosaving, and that their text is kept.
|
|
126
|
+
* Purely additive: a caller that ignores it behaves exactly as before EXCEPT that refused writes
|
|
127
|
+
* are no longer retried forever. */
|
|
128
|
+
staleBasis: StandingDraftStaleBasis | null;
|
|
129
|
+
/** Clears `recoverableDraft` locally only (no server call) — the "Discard" affordance calls
|
|
130
|
+
* {@link clearStandingDraft} instead, which also tells the server; this is for a caller that
|
|
131
|
+
* wants to stop showing the banner without discarding anything (e.g. "Restore" applied it into
|
|
132
|
+
* the editor, and a real Save moments later will clear the server-side draft on its own). */
|
|
133
|
+
dismissRecoverable: () => void;
|
|
134
|
+
/** Call on every content change the caller wants protected — cheap, debounced internally
|
|
135
|
+
* (idle 3s / 15s hard ceiling while continuously typing), safe to call on every keystroke.
|
|
136
|
+
* Whatever is still sitting in the debounce window when the editor goes away is written by the
|
|
137
|
+
* flush this hook wires up itself — see the `flushPendingAutosave` effects below. */
|
|
138
|
+
scheduleAutosave: (draft: StandingDraftAutosaveInput) => void;
|
|
139
|
+
/**
|
|
140
|
+
* Clears the server-side standing draft — call after a real Save/Publish succeeds, or when the
|
|
141
|
+
* operator explicitly discards a recovered draft. Cancels any pending (not yet fired) debounce
|
|
142
|
+
* timer FIRST, then enqueues the discard onto the same serial request chain every
|
|
143
|
+
* `scheduleAutosave` tick uses, so a still-in-flight (already fired) autosave write is guaranteed
|
|
144
|
+
* to reach the server before this discard does, and no new one can be queued after it — the
|
|
145
|
+
* ordering guarantee that keeps a late autosave from resurrecting content a real save (or an
|
|
146
|
+
* explicit discard) just superseded.
|
|
147
|
+
*/
|
|
148
|
+
clearStandingDraft: () => Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* The caller replaced its basis with a newer saved row it did not write itself (an assistant
|
|
151
|
+
* write applied by `use-external-entry-refresh.hooks.ts`, silently or through Load latest). Every
|
|
152
|
+
* write built on an OLDER `baseVersion` is superseded from here on: a pending one is cancelled, a
|
|
153
|
+
* later `scheduleAutosave` on one is dropped (a render that committed before the replacement can
|
|
154
|
+
* still run its autosave effect after this call), and the refusal of one already in flight is
|
|
155
|
+
* ignored. Without this, that write reached the server, was refused, paused autosaving and
|
|
156
|
+
* mirrored text for a version the editor had already left (reviewer finding 2, 2026-09-16).
|
|
157
|
+
*
|
|
158
|
+
* A gate recorded on an older basis lifts, because the editor is no longer on it. That refusal's
|
|
159
|
+
* mirror is kept: nothing has accepted its text anywhere (see this file's header). Does not touch
|
|
160
|
+
* the server-side draft — Load latest also calls {@link clearStandingDraft} for that.
|
|
161
|
+
*/
|
|
162
|
+
supersedeBasis: (baseVersion: number) => void;
|
|
163
|
+
preserveFailedSave: (requiredArgs: {
|
|
164
|
+
error: unknown;
|
|
165
|
+
draft: StandingDraftAutosaveInput;
|
|
166
|
+
}, optionalArgs?: Record<string, never>) => void;
|
|
167
|
+
}
|
|
168
|
+
export interface StandingDraftAutosaveOptions {
|
|
169
|
+
port: StandingDraftAutosavePort;
|
|
170
|
+
backup: ReturnType<typeof createStandingDraftLocalBackup>;
|
|
171
|
+
/** `null` before the entry has loaded (or for a not-yet-created entry) — every operation no-ops. */
|
|
172
|
+
entryId: string | null;
|
|
173
|
+
/** Gate for "this editor is in a state where autosave should run at all" (e.g. not while a save
|
|
174
|
+
* or delete is already in flight). Recovery-check and scheduling both respect it. */
|
|
175
|
+
enabled: boolean;
|
|
176
|
+
serverUpdatedAt?: string | undefined;
|
|
177
|
+
}
|
|
178
|
+
export declare function useStandingDraftAutosave(deps: StandingDraftAutosaveOptions, _options?: Record<string, never>): StandingDraftAutosaveController;
|
|
179
|
+
//# sourceMappingURL=use-standing-draft-autosave.hooks.d.ts.map
|