@startsimpli/ui 0.4.137 → 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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.138",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"react": "^18.0.0 || ^19.0.0",
|
|
50
50
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
51
51
|
"@startsimpli/auth": "^0.4.38",
|
|
52
|
-
"@startsimpli/
|
|
53
|
-
"@startsimpli/
|
|
52
|
+
"@startsimpli/api": "^0.5.66",
|
|
53
|
+
"@startsimpli/i18n": "^0.1.3"
|
|
54
54
|
},
|
|
55
55
|
"peerDependenciesMeta": {
|
|
56
56
|
"@startsimpli/api": {
|
|
@@ -385,3 +385,63 @@ describe('resolving is a deliberate act', () => {
|
|
|
385
385
|
expect(result.current.version).toBe(2);
|
|
386
386
|
});
|
|
387
387
|
});
|
|
388
|
+
|
|
389
|
+
describe('a resolution cannot be raced', () => {
|
|
390
|
+
// The dialog disables these while busy, but the HOOK is exported on its own
|
|
391
|
+
// and a consumer wires its own controls (bd startsim-j19hf does exactly that).
|
|
392
|
+
it('ignores acceptTheirs mid-keepMine so the two cannot interleave', async () => {
|
|
393
|
+
let release: (v: ConditionalWriteOutcome) => void = () => {};
|
|
394
|
+
const second = new Promise<ConditionalWriteOutcome>((r) => {
|
|
395
|
+
release = r;
|
|
396
|
+
});
|
|
397
|
+
let call = 0;
|
|
398
|
+
const write = jest.fn(async () => {
|
|
399
|
+
call += 1;
|
|
400
|
+
return call === 1 ? REFUSAL : second;
|
|
401
|
+
});
|
|
402
|
+
const { result } = renderHook(() => useConditionalSave({ client: { write }, version: 2 }));
|
|
403
|
+
|
|
404
|
+
await act(async () => {
|
|
405
|
+
await result.current.save({ data: { blog: 'mine' } });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
let inflight!: Promise<unknown>;
|
|
409
|
+
act(() => {
|
|
410
|
+
inflight = result.current.keepMine();
|
|
411
|
+
});
|
|
412
|
+
expect(result.current.resolving).toBe(true);
|
|
413
|
+
|
|
414
|
+
// The user hits "discard mine" while the overwrite is still in the air.
|
|
415
|
+
act(() => result.current.acceptTheirs());
|
|
416
|
+
// IGNORED, so the two never interleave: the conflict is still the one
|
|
417
|
+
// keepMine is resolving, not a half-cleared state.
|
|
418
|
+
expect(result.current.conflict?.currentVersion).toBe(3);
|
|
419
|
+
expect(result.current.version).toBe(2);
|
|
420
|
+
|
|
421
|
+
await act(async () => {
|
|
422
|
+
release({ status: 412, body: { detail: 'again', current_version: 9 } });
|
|
423
|
+
await inflight;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// One writer decided the outcome — keepMine's — rather than two racing.
|
|
427
|
+
expect(result.current.conflict?.currentVersion).toBe(9);
|
|
428
|
+
expect(result.current.conflict?.refusedBody).toEqual({ data: { blog: 'mine' } });
|
|
429
|
+
expect(result.current.paused).toBe(true);
|
|
430
|
+
expect(result.current.version).toBe(2);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('does not fire two overwrites on a double click', async () => {
|
|
434
|
+
const { client, sent } = writer([REFUSAL, { status: 200, body: { version: 4 } }]);
|
|
435
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
436
|
+
|
|
437
|
+
await act(async () => {
|
|
438
|
+
await result.current.save({ data: { blog: 'mine' } });
|
|
439
|
+
});
|
|
440
|
+
await act(async () => {
|
|
441
|
+
await Promise.all([result.current.keepMine(), result.current.keepMine()]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// One refused save + exactly one overwrite.
|
|
445
|
+
expect(sent).toHaveLength(2);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
@@ -171,6 +171,9 @@ export function useConditionalSave({
|
|
|
171
171
|
const [conflict, setConflict] = useState<StaleSaveConflict | null>(null);
|
|
172
172
|
const [dismissed, setDismissed] = useState(false);
|
|
173
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);
|
|
174
177
|
const [lastError, setLastError] = useState<unknown>(null);
|
|
175
178
|
|
|
176
179
|
// Read inside `save` so a stale closure cannot send a version the hook has
|
|
@@ -301,6 +304,13 @@ export function useConditionalSave({
|
|
|
301
304
|
const keepMine = useCallback(async (): Promise<ConditionalSaveResult> => {
|
|
302
305
|
const current = conflictRef.current;
|
|
303
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;
|
|
304
314
|
// Adopting `current_version` is legitimate HERE and nowhere else: a person
|
|
305
315
|
// has been told what changed and has chosen to replace it.
|
|
306
316
|
const target = isVersion(current.currentVersion) ? current.currentVersion : undefined;
|
|
@@ -311,11 +321,17 @@ export function useConditionalSave({
|
|
|
311
321
|
try {
|
|
312
322
|
return await attempt(current.refusedBody, target);
|
|
313
323
|
} finally {
|
|
324
|
+
resolvingRef.current = false;
|
|
314
325
|
if (alive.current) setResolving(false);
|
|
315
326
|
}
|
|
316
327
|
}, [attempt]);
|
|
317
328
|
|
|
318
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;
|
|
319
335
|
const current = conflictRef.current;
|
|
320
336
|
setVersionState(isVersion(current?.currentVersion) ? current!.currentVersion : undefined);
|
|
321
337
|
setConflict(null);
|