@startsimpli/ui 0.4.136 → 0.4.138
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/package.json +4 -4
- package/src/components/history/StaleSaveDialog.tsx +277 -0
- package/src/components/history/__tests__/conditional-write.test.ts +252 -0
- package/src/components/history/__tests__/stale-save-dialog.test.tsx +224 -0
- package/src/components/history/__tests__/use-conditional-save.test.tsx +447 -0
- package/src/components/history/conditional-write.ts +340 -0
- package/src/components/history/index.ts +64 -1
- package/src/components/history/stale-save-dialog-default-class-names.ts +45 -0
- package/src/components/history/useConditionalSave.ts +372 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useConditionalSave — every write carries the version it loaded, and a refusal
|
|
5
|
+
* KEEPS THE REVIEWER'S TEXT (bd startsim-jkkn7.3; server bd startsim-3c2wc).
|
|
6
|
+
*
|
|
7
|
+
* The loss this closes, as a person experiences it: two reviewers open one
|
|
8
|
+
* draft, the second save carries the first reviewer's field as the second page
|
|
9
|
+
* loaded it, the rewrite is gone and the response is 200. The server now refuses
|
|
10
|
+
* that second save with a 412 — and this hook is the half that decides what
|
|
11
|
+
* happens to the three paragraphs the reviewer had just typed.
|
|
12
|
+
*
|
|
13
|
+
* ── THE ONE INVARIANT, AND IT IS THE WHOLE BEAD ─────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* A 412 NEVER ADVANCES THE HELD VERSION, AND NEVER RETRIES.
|
|
16
|
+
*
|
|
17
|
+
* The tempting shortcut is to read `current_version` off the refusal, adopt it,
|
|
18
|
+
* and re-send. It makes the error go away and every happy-path test still
|
|
19
|
+
* passes — and it reduces the guard to a decoration, because a blind retry at
|
|
20
|
+
* the current version is EXACTLY the overwrite the 412 just prevented, with one
|
|
21
|
+
* extra round-trip. So the held version advances from one place only: a write
|
|
22
|
+
* that SUCCEEDED. Adopting `current_version` is a deliberate act a person takes
|
|
23
|
+
* ({@link ConditionalSave.keepMine} / {@link ConditionalSave.acceptTheirs}), and
|
|
24
|
+
* `a refusal does not advance the held version` is a test, not a comment.
|
|
25
|
+
*
|
|
26
|
+
* ── WHAT "KEEP THE USER'S TEXT" MEANS MECHANICALLY ──────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* Three things, because the promise is worth nothing as an intention:
|
|
29
|
+
*
|
|
30
|
+
* 1. THIS HOOK NEVER OWNS THE DRAFT and never clears, resets or reverts it. A
|
|
31
|
+
* refused save touches no local state but the conflict itself, so whatever is
|
|
32
|
+
* in the editor stays in the editor.
|
|
33
|
+
* 2. THE REFUSED PAYLOAD IS RETAINED, on {@link StaleSaveConflict.refusedBody}.
|
|
34
|
+
* This is the part that survives the ACCIDENTAL path, which is not the user
|
|
35
|
+
* clicking something rash — it is the host refetching the record to show
|
|
36
|
+
* "what changed" and its own load-into-editor effect overwriting the text
|
|
37
|
+
* with the server's copy. The bytes are still here when that happens, so the
|
|
38
|
+
* reviewer's work is recoverable rather than merely promised.
|
|
39
|
+
* 3. AUTOSAVE PAUSES AND THE CONFLICT SURFACES ONCE. The draft editor debounces
|
|
40
|
+
* at 1,200 ms, so a 412 that neither paused nor deduplicated would repaint a
|
|
41
|
+
* modal on every keystroke burst — worse than no dialog at all. While
|
|
42
|
+
* {@link ConditionalSave.paused} holds, `save` returns without sending.
|
|
43
|
+
*
|
|
44
|
+
* DISMISSING IS NOT RESOLVING. Escape, the backdrop and the close button all
|
|
45
|
+
* land on {@link ConditionalSave.dismissConflict}, which closes the dialog and
|
|
46
|
+
* leaves the conflict and the pause in place. The reviewer keeps their text and
|
|
47
|
+
* can still reopen; what they cannot do is wander back into autosaving over
|
|
48
|
+
* somebody else's change because they pressed Escape.
|
|
49
|
+
*/
|
|
50
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
isVersion,
|
|
54
|
+
parseStaleSaveRefusal,
|
|
55
|
+
preconditionFor,
|
|
56
|
+
versionFromWriteOutcome,
|
|
57
|
+
newestRevision,
|
|
58
|
+
type PreconditionSpelling,
|
|
59
|
+
type ResponseHeaders,
|
|
60
|
+
} from './conditional-write';
|
|
61
|
+
import { normalizeRevision, type NormalizedRevision } from './revision-history';
|
|
62
|
+
import type { RevisionClient } from './types';
|
|
63
|
+
|
|
64
|
+
/** What the host sends. Merge `headers` into the request headers and `body` is
|
|
65
|
+
* the request body — the precondition is already in whichever one the chosen
|
|
66
|
+
* spelling uses. */
|
|
67
|
+
export interface ConditionalWriteAttempt {
|
|
68
|
+
body: Record<string, unknown>;
|
|
69
|
+
headers: Record<string, string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the host hands back. A plain `fetch` adapter is about eight lines. */
|
|
73
|
+
export interface ConditionalWriteOutcome {
|
|
74
|
+
status?: number;
|
|
75
|
+
body?: unknown;
|
|
76
|
+
headers?: ResponseHeaders;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The one call this hook needs, injected exactly like {@link RevisionClient} —
|
|
81
|
+
* which is what keeps @startsimpli/api out of packages/ui.
|
|
82
|
+
*
|
|
83
|
+
* It may RESOLVE with a 412 outcome or THROW one; both are understood, because
|
|
84
|
+
* a plain `fetch` does the first and a wrapper does the second.
|
|
85
|
+
*/
|
|
86
|
+
export interface ConditionalWriteClient {
|
|
87
|
+
write: (attempt: ConditionalWriteAttempt) => Promise<ConditionalWriteOutcome>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Why the dialog cannot name a person. Each reads differently to a reviewer,
|
|
91
|
+
* and none of them may be rendered as "a machine did this". */
|
|
92
|
+
export type IdentityGap = 'forbidden' | 'empty' | 'error' | 'no-client';
|
|
93
|
+
|
|
94
|
+
export interface StaleSaveConflict {
|
|
95
|
+
/** The version the record is at NOW, straight off the refusal. */
|
|
96
|
+
currentVersion?: number;
|
|
97
|
+
/** The server's sentence. It names both versions and is safe to show. */
|
|
98
|
+
detail?: string;
|
|
99
|
+
/** The version the editor was holding when it was refused. */
|
|
100
|
+
sentVersion?: number;
|
|
101
|
+
/**
|
|
102
|
+
* THE REVIEWER'S TEXT, as the server refused it. Retained so it is
|
|
103
|
+
* recoverable even if the host reloaded the server's copy over the editor.
|
|
104
|
+
*/
|
|
105
|
+
refusedBody: Record<string, unknown>;
|
|
106
|
+
/** The newest row of the trail — who changed it, when, and which fields.
|
|
107
|
+
* Absent whenever {@link identityGap} is set. */
|
|
108
|
+
changedBy?: NormalizedRevision;
|
|
109
|
+
/** Set when the trail could not say who. The dialog still works. */
|
|
110
|
+
identityGap?: IdentityGap;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type ConditionalSaveResult =
|
|
114
|
+
| { status: 'saved'; version?: number; outcome: ConditionalWriteOutcome }
|
|
115
|
+
| { status: 'refused'; conflict: StaleSaveConflict }
|
|
116
|
+
/** A conflict is already open: nothing was sent. */
|
|
117
|
+
| { status: 'paused' }
|
|
118
|
+
| { status: 'failed'; error: unknown };
|
|
119
|
+
|
|
120
|
+
export interface UseConditionalSaveOptions {
|
|
121
|
+
client: ConditionalWriteClient;
|
|
122
|
+
/** The version the record was loaded at. Seeding this is what makes the FIRST
|
|
123
|
+
* save guarded; without it the first write is unguarded and the trail says
|
|
124
|
+
* so (`precondition: "none"`). */
|
|
125
|
+
version?: number;
|
|
126
|
+
/** Reads `/api/v1/entities/<id>/revisions/` for the human-readable half. The
|
|
127
|
+
* 412 carries no identity by design (bd startsim-j4kx6) — without this the
|
|
128
|
+
* dialog still opens and simply does not name anyone. */
|
|
129
|
+
revisions?: RevisionClient;
|
|
130
|
+
/** `header` (default) sends `If-Match` and leaves the body untouched. */
|
|
131
|
+
spelling?: PreconditionSpelling;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ConditionalSave {
|
|
135
|
+
/** The version every write will assert. `undefined` means unguarded. */
|
|
136
|
+
version?: number;
|
|
137
|
+
/** Seed or re-seed it — on load, and after the host reloads the record. */
|
|
138
|
+
setVersion: (version: number | undefined) => void;
|
|
139
|
+
/** Whether the next save will actually assert a version. */
|
|
140
|
+
guarded: boolean;
|
|
141
|
+
save: (body: Record<string, unknown>) => Promise<ConditionalSaveResult>;
|
|
142
|
+
conflict: StaleSaveConflict | null;
|
|
143
|
+
/** An unresolved conflict: autosave must not fire. Stays true across a
|
|
144
|
+
* dismissal, because dismissing is not resolving. */
|
|
145
|
+
paused: boolean;
|
|
146
|
+
/** Whether the dialog should be on screen. */
|
|
147
|
+
dialogOpen: boolean;
|
|
148
|
+
/** Close the dialog. KEEPS the text, the conflict and the pause. */
|
|
149
|
+
dismissConflict: () => void;
|
|
150
|
+
reopenConflict: () => void;
|
|
151
|
+
/** Deliberately overwrite their change with the refused text, at the version
|
|
152
|
+
* the server named. The only path that adopts `current_version`. */
|
|
153
|
+
keepMine: () => Promise<ConditionalSaveResult>;
|
|
154
|
+
/** Give up the local edit: clear the conflict and adopt their version so the
|
|
155
|
+
* host can reload. Destructive to the reviewer's text BY DESIGN, which is
|
|
156
|
+
* why nothing but an explicit choice reaches it. */
|
|
157
|
+
acceptTheirs: () => void;
|
|
158
|
+
/** A `keepMine` is in flight. */
|
|
159
|
+
resolving: boolean;
|
|
160
|
+
/** The last non-412 failure, since `save` reports rather than throws. */
|
|
161
|
+
lastError: unknown;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function useConditionalSave({
|
|
165
|
+
client,
|
|
166
|
+
version: initialVersion,
|
|
167
|
+
revisions,
|
|
168
|
+
spelling = 'header',
|
|
169
|
+
}: UseConditionalSaveOptions): ConditionalSave {
|
|
170
|
+
const [version, setVersionState] = useState<number | undefined>(initialVersion);
|
|
171
|
+
const [conflict, setConflict] = useState<StaleSaveConflict | null>(null);
|
|
172
|
+
const [dismissed, setDismissed] = useState(false);
|
|
173
|
+
const [resolving, setResolving] = useState(false);
|
|
174
|
+
/** The synchronous twin of {@link resolving}. State lands a render too late to
|
|
175
|
+
* stop a second click; this does not. */
|
|
176
|
+
const resolvingRef = useRef(false);
|
|
177
|
+
const [lastError, setLastError] = useState<unknown>(null);
|
|
178
|
+
|
|
179
|
+
// Read inside `save` so a stale closure cannot send a version the hook has
|
|
180
|
+
// already moved past, and so `paused` is evaluated against the live conflict
|
|
181
|
+
// rather than the one captured when the debounce was scheduled.
|
|
182
|
+
const versionRef = useRef(version);
|
|
183
|
+
versionRef.current = version;
|
|
184
|
+
const conflictRef = useRef(conflict);
|
|
185
|
+
conflictRef.current = conflict;
|
|
186
|
+
|
|
187
|
+
// The identity fetch resolves after the conflict is already on screen, so it
|
|
188
|
+
// must not write into a hook that has unmounted or already moved on.
|
|
189
|
+
const alive = useRef(true);
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
alive.current = true;
|
|
192
|
+
return () => {
|
|
193
|
+
alive.current = false;
|
|
194
|
+
};
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
const setVersion = useCallback((next: number | undefined) => {
|
|
198
|
+
setVersionState(isVersion(next) ? next : undefined);
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
// Re-seed when the host loads a different record. Guarded on the value so a
|
|
202
|
+
// save's own advance is not stomped by a re-render carrying the old prop.
|
|
203
|
+
const seeded = useRef(initialVersion);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (seeded.current !== initialVersion) {
|
|
206
|
+
seeded.current = initialVersion;
|
|
207
|
+
setVersionState(isVersion(initialVersion) ? initialVersion : undefined);
|
|
208
|
+
}
|
|
209
|
+
}, [initialVersion]);
|
|
210
|
+
|
|
211
|
+
/** Fill in "who changed it" after the dialog is already up. Never blocks the
|
|
212
|
+
* conflict from surfacing, and never throws into the save path. */
|
|
213
|
+
const resolveIdentity = useCallback(
|
|
214
|
+
async (key: StaleSaveConflict) => {
|
|
215
|
+
const patch = (gap: IdentityGap) => {
|
|
216
|
+
if (!alive.current) return;
|
|
217
|
+
setConflict((current) => (current === key ? { ...current, identityGap: gap } : current));
|
|
218
|
+
};
|
|
219
|
+
if (!revisions) return patch('no-client');
|
|
220
|
+
try {
|
|
221
|
+
const page = await revisions.list({ page: 1, pageSize: 1 });
|
|
222
|
+
const row = newestRevision(page?.results);
|
|
223
|
+
if (!alive.current) return;
|
|
224
|
+
if (!row) return patch('empty');
|
|
225
|
+
setConflict((current) =>
|
|
226
|
+
current === key ? { ...current, changedBy: normalizeRevision(row) } : current,
|
|
227
|
+
);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// A 404 on this nested route means the caller may not read the PARENT
|
|
230
|
+
// record, so history is withheld rather than absent — a different
|
|
231
|
+
// sentence, and never an empty trail.
|
|
232
|
+
patch(parseStatus(error) === 404 ? 'forbidden' : 'error');
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
[revisions],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const refuse = useCallback(
|
|
239
|
+
(
|
|
240
|
+
refusedBody: Record<string, unknown>,
|
|
241
|
+
sent: number | undefined,
|
|
242
|
+
refusal: { currentVersion?: number; detail?: string },
|
|
243
|
+
): ConditionalSaveResult => {
|
|
244
|
+
const next: StaleSaveConflict = {
|
|
245
|
+
...refusal,
|
|
246
|
+
...(sent !== undefined ? { sentVersion: sent } : {}),
|
|
247
|
+
refusedBody,
|
|
248
|
+
};
|
|
249
|
+
if (alive.current) {
|
|
250
|
+
// NOTE WHAT IS NOT HERE: setVersionState. The refusal names the current
|
|
251
|
+
// version and this hook deliberately declines to take it.
|
|
252
|
+
setConflict(next);
|
|
253
|
+
setDismissed(false);
|
|
254
|
+
setLastError(null);
|
|
255
|
+
void resolveIdentity(next);
|
|
256
|
+
}
|
|
257
|
+
return { status: 'refused', conflict: next };
|
|
258
|
+
},
|
|
259
|
+
[resolveIdentity],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const attempt = useCallback(
|
|
263
|
+
async (
|
|
264
|
+
body: Record<string, unknown>,
|
|
265
|
+
assertVersion: number | undefined,
|
|
266
|
+
): Promise<ConditionalSaveResult> => {
|
|
267
|
+
const parts = preconditionFor(assertVersion, spelling);
|
|
268
|
+
try {
|
|
269
|
+
const outcome = await client.write({
|
|
270
|
+
body: { ...body, ...parts.body },
|
|
271
|
+
headers: { ...parts.headers },
|
|
272
|
+
});
|
|
273
|
+
const refusal = parseStaleSaveRefusal(outcome);
|
|
274
|
+
if (refusal) return refuse(body, assertVersion, refusal);
|
|
275
|
+
const next = versionFromWriteOutcome(outcome ?? {});
|
|
276
|
+
if (alive.current) {
|
|
277
|
+
// THE ONLY PLACE THE HELD VERSION ADVANCES.
|
|
278
|
+
if (next !== undefined) setVersionState(next);
|
|
279
|
+
setConflict(null);
|
|
280
|
+
setDismissed(false);
|
|
281
|
+
setLastError(null);
|
|
282
|
+
}
|
|
283
|
+
return { status: 'saved', ...(next !== undefined ? { version: next } : {}), outcome };
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const refusal = parseStaleSaveRefusal(error);
|
|
286
|
+
if (refusal) return refuse(body, assertVersion, refusal);
|
|
287
|
+
if (alive.current) setLastError(error);
|
|
288
|
+
return { status: 'failed', error };
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
[client, refuse, spelling],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const save = useCallback(
|
|
295
|
+
async (body: Record<string, unknown>): Promise<ConditionalSaveResult> => {
|
|
296
|
+
// Surfaced once, not retried. An unresolved conflict means the reviewer
|
|
297
|
+
// still has a decision to make and their keystrokes must not re-send.
|
|
298
|
+
if (conflictRef.current) return { status: 'paused' };
|
|
299
|
+
return attempt(body, versionRef.current);
|
|
300
|
+
},
|
|
301
|
+
[attempt],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const keepMine = useCallback(async (): Promise<ConditionalSaveResult> => {
|
|
305
|
+
const current = conflictRef.current;
|
|
306
|
+
if (!current) return { status: 'paused' };
|
|
307
|
+
// RE-ENTRANCY IS GUARDED ON A REF, NOT ON `resolving`, AND THAT IS THE WHOLE
|
|
308
|
+
// POINT. `setResolving(true)` only reaches the DOM after a re-render, so a
|
|
309
|
+
// double click on "Save mine" lands both handlers before the button is
|
|
310
|
+
// disabled — and sends the overwrite TWICE. Measured, not theorised: the
|
|
311
|
+
// test below counted three writes where two were correct.
|
|
312
|
+
if (resolvingRef.current) return { status: 'paused' };
|
|
313
|
+
resolvingRef.current = true;
|
|
314
|
+
// Adopting `current_version` is legitimate HERE and nowhere else: a person
|
|
315
|
+
// has been told what changed and has chosen to replace it.
|
|
316
|
+
const target = isVersion(current.currentVersion) ? current.currentVersion : undefined;
|
|
317
|
+
// The pause is NOT lifted here. `save` stays refused for the whole of this
|
|
318
|
+
// round-trip, so a debounce that fires mid-resolution cannot send the same
|
|
319
|
+
// body a second time and race the write this call is already making.
|
|
320
|
+
setResolving(true);
|
|
321
|
+
try {
|
|
322
|
+
return await attempt(current.refusedBody, target);
|
|
323
|
+
} finally {
|
|
324
|
+
resolvingRef.current = false;
|
|
325
|
+
if (alive.current) setResolving(false);
|
|
326
|
+
}
|
|
327
|
+
}, [attempt]);
|
|
328
|
+
|
|
329
|
+
const acceptTheirs = useCallback(() => {
|
|
330
|
+
// IGNORED MID-OVERWRITE, same posture as `save`. Discarding while a
|
|
331
|
+
// `keepMine` is in the air lets the two interleave, and the refusal of a
|
|
332
|
+
// save the reviewer has ALREADY ABANDONED then raises a fresh dialog about
|
|
333
|
+
// it — a conflict about text that no longer exists anywhere.
|
|
334
|
+
if (resolvingRef.current) return;
|
|
335
|
+
const current = conflictRef.current;
|
|
336
|
+
setVersionState(isVersion(current?.currentVersion) ? current!.currentVersion : undefined);
|
|
337
|
+
setConflict(null);
|
|
338
|
+
setDismissed(false);
|
|
339
|
+
}, []);
|
|
340
|
+
|
|
341
|
+
const dismissConflict = useCallback(() => setDismissed(true), []);
|
|
342
|
+
const reopenConflict = useCallback(() => setDismissed(false), []);
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
...(version !== undefined ? { version } : {}),
|
|
346
|
+
setVersion,
|
|
347
|
+
guarded: isVersion(version),
|
|
348
|
+
save,
|
|
349
|
+
conflict,
|
|
350
|
+
paused: conflict !== null,
|
|
351
|
+
dialogOpen: conflict !== null && !dismissed,
|
|
352
|
+
dismissConflict,
|
|
353
|
+
reopenConflict,
|
|
354
|
+
keepMine,
|
|
355
|
+
acceptTheirs,
|
|
356
|
+
resolving,
|
|
357
|
+
lastError,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseStatus(error: unknown): number | undefined {
|
|
362
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
363
|
+
const candidate = error as {
|
|
364
|
+
status?: unknown;
|
|
365
|
+
statusCode?: unknown;
|
|
366
|
+
response?: { status?: unknown };
|
|
367
|
+
};
|
|
368
|
+
for (const raw of [candidate.status, candidate.statusCode, candidate.response?.status]) {
|
|
369
|
+
if (typeof raw === 'number') return raw;
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|