@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.137",
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/i18n": "^0.1.3",
53
- "@startsimpli/api": "^0.5.66"
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);