@open-mercato/ui 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509

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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +57 -11
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +20 -8
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/PerspectiveSidebar.js +8 -1
  10. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  11. package/dist/backend/RowActions.js +1 -1
  12. package/dist/backend/RowActions.js.map +2 -2
  13. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  14. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  15. package/dist/backend/conflicts/index.js +32 -0
  16. package/dist/backend/conflicts/index.js.map +7 -0
  17. package/dist/backend/conflicts/store.js +50 -0
  18. package/dist/backend/conflicts/store.js.map +7 -0
  19. package/dist/backend/injection/useGuardedMutation.js +8 -1
  20. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  21. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  23. package/dist/backend/utils/optimisticLock.js +34 -0
  24. package/dist/backend/utils/optimisticLock.js.map +7 -0
  25. package/package.json +7 -3
  26. package/src/backend/AppShell.tsx +10 -0
  27. package/src/backend/CrudForm.tsx +99 -9
  28. package/src/backend/DataTable.tsx +22 -8
  29. package/src/backend/PerspectiveSidebar.tsx +8 -1
  30. package/src/backend/RowActions.tsx +1 -1
  31. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
  32. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  33. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  34. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  35. package/src/backend/conflicts/index.ts +49 -0
  36. package/src/backend/conflicts/store.ts +94 -0
  37. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  38. package/src/backend/injection/useGuardedMutation.ts +16 -1
  39. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  40. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  41. package/src/backend/utils/optimisticLock.ts +70 -0
@@ -0,0 +1,77 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { RefreshCw, X } from 'lucide-react'
4
+ import { Button } from '../../primitives/button'
5
+ import { IconButton } from '../../primitives/icon-button'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { dismissRecordConflict, useRecordConflict } from './store'
8
+
9
+ /**
10
+ * Persistent, error-styled bar that surfaces an optimistic-lock conflict
11
+ * ("this record was modified by someone else"). Rendered once in `AppShell`
12
+ * next to the undo `LastOperationBanner`, it is the unified, clearly-visible
13
+ * surface for the conflict across every form (CrudForm, useGuardedMutation,
14
+ * and custom pages all push through `conflicts/store`). Unlike the undo bar it
15
+ * does NOT auto-dismiss — the user resolves it by refreshing or dismissing.
16
+ */
17
+ export function RecordConflictBanner() {
18
+ const t = useT()
19
+ const conflict = useRecordConflict()
20
+
21
+ const handleRefresh = React.useCallback(() => {
22
+ const onRefresh = conflict?.onRefresh ?? null
23
+ dismissRecordConflict()
24
+ if (onRefresh) {
25
+ onRefresh()
26
+ return
27
+ }
28
+ if (typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
29
+ try {
30
+ const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
31
+ ? navigator.userAgent.toLowerCase().includes('jsdom')
32
+ : false
33
+ if (!isJSDOM) window.location.reload()
34
+ } catch {
35
+ // noop in non-browser / jsdom environments
36
+ }
37
+ }
38
+ }, [conflict])
39
+
40
+ if (!conflict) return null
41
+
42
+ return (
43
+ <div
44
+ role="alert"
45
+ data-testid="record-conflict-banner"
46
+ className="mb-4 flex items-center justify-between gap-3 rounded-md border border-status-error-border bg-status-error-bg pl-3 pr-2 py-2 text-sm text-status-error-text shadow-xs sm:pr-3"
47
+ >
48
+ <div className="min-w-0">
49
+ <span className="font-medium text-status-error-text">
50
+ {conflict.title ?? t('ui.forms.conflict.title', 'Record changed')}
51
+ </span>
52
+ <span className="ml-2 text-status-error-text">{conflict.message}</span>
53
+ </div>
54
+ <div className="flex items-center gap-1">
55
+ <Button
56
+ type="button"
57
+ variant="outline"
58
+ size="sm"
59
+ onClick={handleRefresh}
60
+ className="border-status-error-border bg-status-error-bg text-status-error-text hover:bg-status-error-border hover:text-status-error-text px-2.5 sm:px-3"
61
+ >
62
+ <RefreshCw className="mr-1 size-4" aria-hidden="true" />
63
+ {t('ui.forms.conflict.refresh', 'Refresh')}
64
+ </Button>
65
+ <IconButton
66
+ type="button"
67
+ variant="ghost"
68
+ size="sm"
69
+ aria-label={t('ui.forms.conflict.dismiss', 'Dismiss')}
70
+ onClick={() => dismissRecordConflict()}
71
+ >
72
+ <X className="size-4" aria-hidden="true" />
73
+ </IconButton>
74
+ </div>
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,61 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { act, render, screen } from '@testing-library/react'
4
+
5
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
6
+ useT: () => (key: string, fallback?: string) => fallback ?? key,
7
+ }))
8
+
9
+ import { RecordConflictBanner } from '../RecordConflictBanner'
10
+ import { dismissRecordConflict, showRecordConflict } from '../store'
11
+
12
+ describe('RecordConflictBanner', () => {
13
+ beforeEach(() => dismissRecordConflict())
14
+ afterEach(() => dismissRecordConflict())
15
+
16
+ it('renders nothing when there is no active conflict', () => {
17
+ const { container } = render(<RecordConflictBanner />)
18
+ expect(container.querySelector('[data-testid="record-conflict-banner"]')).toBeNull()
19
+ })
20
+
21
+ it('renders the localized message + Refresh/Dismiss as an alert when a conflict is active', () => {
22
+ render(<RecordConflictBanner />)
23
+ act(() => {
24
+ showRecordConflict({
25
+ message: 'This record was modified by someone else. Refresh and try again.',
26
+ })
27
+ })
28
+ const banner = screen.getByTestId('record-conflict-banner')
29
+ expect(banner.getAttribute('role')).toBe('alert')
30
+ expect(banner.textContent).toContain('Record changed')
31
+ expect(banner.textContent).toContain('This record was modified by someone else. Refresh and try again.')
32
+ expect(screen.getByRole('button', { name: 'Refresh' })).not.toBeNull()
33
+ expect(screen.getByRole('button', { name: 'Dismiss' })).not.toBeNull()
34
+ })
35
+
36
+ it('invokes a custom onRefresh and clears the bar when Refresh is clicked', () => {
37
+ const onRefresh = jest.fn()
38
+ render(<RecordConflictBanner />)
39
+ act(() => {
40
+ showRecordConflict({ message: 'Changed', onRefresh })
41
+ })
42
+ act(() => {
43
+ screen.getByRole('button', { name: 'Refresh' }).click()
44
+ })
45
+ expect(onRefresh).toHaveBeenCalledTimes(1)
46
+ expect(screen.queryByTestId('record-conflict-banner')).toBeNull()
47
+ })
48
+
49
+ it('Dismiss clears the bar without refreshing', () => {
50
+ const onRefresh = jest.fn()
51
+ render(<RecordConflictBanner />)
52
+ act(() => {
53
+ showRecordConflict({ message: 'Changed', onRefresh })
54
+ })
55
+ act(() => {
56
+ screen.getByRole('button', { name: 'Dismiss' }).click()
57
+ })
58
+ expect(onRefresh).not.toHaveBeenCalled()
59
+ expect(screen.queryByTestId('record-conflict-banner')).toBeNull()
60
+ })
61
+ })
@@ -0,0 +1,78 @@
1
+ /** @jest-environment jsdom */
2
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
3
+ import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
4
+ import {
5
+ dismissRecordConflict,
6
+ getRecordConflictForTest,
7
+ showRecordConflict,
8
+ surfaceRecordConflict,
9
+ } from '..'
10
+
11
+ const t = (key: string, fallback?: string) => fallback ?? key
12
+
13
+ describe('record conflict store + surfaceRecordConflict', () => {
14
+ beforeEach(() => dismissRecordConflict())
15
+
16
+ it('showRecordConflict sets the active entry; dismiss clears it', () => {
17
+ expect(getRecordConflictForTest()).toBeNull()
18
+ showRecordConflict({ message: 'Changed', currentUpdatedAt: '2026-05-25T08:00:01.000Z' })
19
+ const entry = getRecordConflictForTest()
20
+ expect(entry?.message).toBe('Changed')
21
+ expect(entry?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
22
+ dismissRecordConflict()
23
+ expect(getRecordConflictForTest()).toBeNull()
24
+ })
25
+
26
+ it('surfaceRecordConflict pushes the localized message for an optimistic-lock 409 and returns true', () => {
27
+ const conflict = new CrudHttpError(409, {
28
+ error: 'record_modified',
29
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
30
+ currentUpdatedAt: '2026-05-25T08:00:01.000Z',
31
+ expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
32
+ })
33
+ const handled = surfaceRecordConflict(conflict, t)
34
+ expect(handled).toBe(true)
35
+ expect(getRecordConflictForTest()?.message).toBe(
36
+ 'This record was modified by someone else. Refresh and try again.',
37
+ )
38
+ })
39
+
40
+ it('surfaceRecordConflict recognizes a plain apiCall-result envelope ({ status, body }) — used by custom admin pages whose apiCall does not throw on 409', () => {
41
+ const apiCallEnvelope = {
42
+ status: 409,
43
+ body: {
44
+ error: 'record_modified',
45
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
46
+ currentUpdatedAt: '2026-05-25T08:00:01.000Z',
47
+ expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
48
+ },
49
+ }
50
+ expect(surfaceRecordConflict(apiCallEnvelope, t)).toBe(true)
51
+ expect(getRecordConflictForTest()?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
52
+ })
53
+
54
+ it('surfaceRecordConflict ignores a non-409 apiCall envelope (e.g. 500 with no conflict body)', () => {
55
+ expect(surfaceRecordConflict({ status: 500, body: { error: 'server_error' } }, t)).toBe(false)
56
+ expect(getRecordConflictForTest()).toBeNull()
57
+ })
58
+
59
+ it('surfaceRecordConflict is a no-op (returns false) for non-conflict errors', () => {
60
+ expect(surfaceRecordConflict(new CrudHttpError(422, { error: 'validation_failed' }), t)).toBe(false)
61
+ expect(surfaceRecordConflict(new Error('boom'), t)).toBe(false)
62
+ expect(getRecordConflictForTest()).toBeNull()
63
+ })
64
+
65
+ it('forwards a custom onRefresh callback', () => {
66
+ const onRefresh = jest.fn()
67
+ const conflict = new CrudHttpError(409, {
68
+ error: 'record_modified',
69
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
70
+ currentUpdatedAt: '2026-05-25T08:00:01.000Z',
71
+ expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
72
+ })
73
+ surfaceRecordConflict(conflict, t, { onRefresh, title: 'Order changed' })
74
+ const entry = getRecordConflictForTest()
75
+ expect(entry?.title).toBe('Order changed')
76
+ expect(entry?.onRefresh).toBe(onRefresh)
77
+ })
78
+ })
@@ -0,0 +1,49 @@
1
+ import { extractOptimisticLockConflict } from '../utils/optimisticLock'
2
+ import { showRecordConflict } from './store'
3
+
4
+ export {
5
+ showRecordConflict,
6
+ dismissRecordConflict,
7
+ useRecordConflict,
8
+ getRecordConflictForTest,
9
+ type RecordConflictEntry,
10
+ type ShowRecordConflictInput,
11
+ } from './store'
12
+ export { RecordConflictBanner } from './RecordConflictBanner'
13
+
14
+ type Translate = (key: string, fallback?: string) => string
15
+
16
+ export type SurfaceRecordConflictOptions = {
17
+ /** Custom refresh handler. Omit to let the banner reload the page. */
18
+ onRefresh?: (() => void) | null
19
+ /** Localized title override; the banner falls back to a generic title. */
20
+ title?: string | null
21
+ }
22
+
23
+ /**
24
+ * If `error` is an OSS optimistic-lock conflict (HTTP 409 with
25
+ * `code: 'optimistic_lock_conflict'`), push the localized "record modified"
26
+ * message onto the shared conflict bar and return `true`. Otherwise return
27
+ * `false` so callers can fall back to their normal error handling.
28
+ *
29
+ * This is the single entry point every form uses, so the conflict surfaces the
30
+ * same persistent, error-styled bar everywhere.
31
+ */
32
+ export function surfaceRecordConflict(
33
+ error: unknown,
34
+ t: Translate,
35
+ options: SurfaceRecordConflictOptions = {},
36
+ ): boolean {
37
+ const conflict = extractOptimisticLockConflict(error)
38
+ if (!conflict) return false
39
+ showRecordConflict({
40
+ message: t(
41
+ 'ui.forms.flash.recordModified',
42
+ 'This record was modified by someone else. Refresh and try again.',
43
+ ),
44
+ title: options.title ?? null,
45
+ currentUpdatedAt: conflict.currentUpdatedAt,
46
+ onRefresh: options.onRefresh ?? null,
47
+ })
48
+ return true
49
+ }
@@ -0,0 +1,94 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+
4
+ /**
5
+ * In-memory store for the unified record-conflict bar.
6
+ *
7
+ * When a mutation fails with an OSS optimistic-lock conflict (HTTP 409 with
8
+ * `code: 'optimistic_lock_conflict'`), forms surface it through this store so a
9
+ * persistent, error-styled bar — the `RecordConflictBanner` rendered once in
10
+ * `AppShell` — shows the "this record was modified" message app-wide, instead
11
+ * of a transient toast that scrolls away. Mirrors the undo `LastOperationBanner`
12
+ * pattern but for the error case, and is shared by `CrudForm`,
13
+ * `useGuardedMutation`, and any custom page, so every form behaves the same.
14
+ *
15
+ * Unlike the operations store this is NOT persisted to localStorage — it holds
16
+ * a live `onRefresh` callback and a conflict only matters for the current page
17
+ * session.
18
+ */
19
+ export type RecordConflictEntry = {
20
+ id: string
21
+ /** Localized, user-facing message (e.g. ui.forms.flash.recordModified). */
22
+ message: string
23
+ /** Optional localized title; the banner falls back to a generic title. */
24
+ title?: string | null
25
+ /** The server's current `updated_at`, for diagnostics / future diffing. */
26
+ currentUpdatedAt?: string | null
27
+ /**
28
+ * Invoked when the user clicks "Refresh". When omitted the banner reloads
29
+ * the page (the safe default — re-fetches the latest server state).
30
+ */
31
+ onRefresh?: (() => void) | null
32
+ createdAt: number
33
+ }
34
+
35
+ let internalEntry: RecordConflictEntry | null = null
36
+ let sequence = 0
37
+
38
+ const emitter = new EventTarget()
39
+
40
+ function emit(): void {
41
+ emitter.dispatchEvent(new Event('change'))
42
+ }
43
+
44
+ function subscribe(listener: () => void): () => void {
45
+ const wrapped = () => listener()
46
+ emitter.addEventListener('change', wrapped)
47
+ return () => emitter.removeEventListener('change', wrapped)
48
+ }
49
+
50
+ function nextId(): string {
51
+ sequence += 1
52
+ return `conflict:${sequence}`
53
+ }
54
+
55
+ export type ShowRecordConflictInput = {
56
+ message: string
57
+ title?: string | null
58
+ currentUpdatedAt?: string | null
59
+ onRefresh?: (() => void) | null
60
+ }
61
+
62
+ /** Show (or replace) the active record-conflict bar. */
63
+ export function showRecordConflict(input: ShowRecordConflictInput): void {
64
+ internalEntry = {
65
+ id: nextId(),
66
+ message: input.message,
67
+ title: input.title ?? null,
68
+ currentUpdatedAt: input.currentUpdatedAt ?? null,
69
+ onRefresh: input.onRefresh ?? null,
70
+ createdAt: typeof Date !== 'undefined' ? Date.now() : 0,
71
+ }
72
+ emit()
73
+ }
74
+
75
+ /** Clear the active record-conflict bar. */
76
+ export function dismissRecordConflict(): void {
77
+ if (internalEntry === null) return
78
+ internalEntry = null
79
+ emit()
80
+ }
81
+
82
+ /** Read the active conflict (null when none). Subscribes to changes. */
83
+ export function useRecordConflict(): RecordConflictEntry | null {
84
+ return React.useSyncExternalStore(
85
+ subscribe,
86
+ () => internalEntry,
87
+ () => null,
88
+ )
89
+ }
90
+
91
+ /** Test-only accessor for the current entry without React. */
92
+ export function getRecordConflictForTest(): RecordConflictEntry | null {
93
+ return internalEntry
94
+ }
@@ -0,0 +1,118 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
+ import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
5
+
6
+ const triggerEventMock = jest.fn()
7
+
8
+ jest.mock('../InjectionSpot', () => ({
9
+ useInjectionSpotEvents: () => ({
10
+ triggerEvent: (...args: unknown[]) => triggerEventMock(...args),
11
+ }),
12
+ }))
13
+
14
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
15
+ useT: () => (key: string, fallback?: string) => fallback ?? key,
16
+ }))
17
+
18
+ import { act, renderHook } from '@testing-library/react'
19
+ import { useGuardedMutation } from '../useGuardedMutation'
20
+ import { dismissRecordConflict, getRecordConflictForTest } from '../../conflicts'
21
+
22
+ function setupTriggerEventOk() {
23
+ triggerEventMock.mockImplementation(async () => ({ ok: true, requestHeaders: undefined }))
24
+ }
25
+
26
+ describe('useGuardedMutation — surfaces optimistic-lock conflict on the unified bar', () => {
27
+ beforeEach(() => {
28
+ triggerEventMock.mockClear()
29
+ dismissRecordConflict()
30
+ })
31
+
32
+ it('shows the record-conflict bar when the operation throws CrudHttpError(409, optimistic_lock_conflict)', async () => {
33
+ setupTriggerEventOk()
34
+ const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
35
+ const conflict = new CrudHttpError(409, {
36
+ error: 'record_modified',
37
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
38
+ currentUpdatedAt: '2026-05-25T08:00:01.000Z',
39
+ expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
40
+ })
41
+
42
+ await act(async () => {
43
+ try {
44
+ await result.current.runMutation({
45
+ operation: async () => { throw conflict },
46
+ context: {},
47
+ mutationPayload: {},
48
+ })
49
+ } catch (err) {
50
+ expect(err).toBe(conflict)
51
+ }
52
+ })
53
+
54
+ const entry = getRecordConflictForTest()
55
+ expect(entry).not.toBeNull()
56
+ expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
57
+ expect(entry?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
58
+ })
59
+
60
+ it('does NOT show the bar for non-optimistic-lock 409s (e.g. plain conflicts)', async () => {
61
+ setupTriggerEventOk()
62
+ const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
63
+ const otherConflict = new CrudHttpError(409, {
64
+ error: 'duplicate',
65
+ code: 'duplicate_resource',
66
+ })
67
+
68
+ await act(async () => {
69
+ try {
70
+ await result.current.runMutation({
71
+ operation: async () => { throw otherConflict },
72
+ context: {},
73
+ mutationPayload: {},
74
+ })
75
+ } catch (err) {
76
+ expect(err).toBe(otherConflict)
77
+ }
78
+ })
79
+
80
+ expect(getRecordConflictForTest()).toBeNull()
81
+ })
82
+
83
+ it('does NOT show the bar for non-409 errors', async () => {
84
+ setupTriggerEventOk()
85
+ const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
86
+ const validationError = new CrudHttpError(422, { error: 'validation_failed' })
87
+
88
+ await act(async () => {
89
+ try {
90
+ await result.current.runMutation({
91
+ operation: async () => { throw validationError },
92
+ context: {},
93
+ mutationPayload: {},
94
+ })
95
+ } catch (err) {
96
+ expect(err).toBe(validationError)
97
+ }
98
+ })
99
+
100
+ expect(getRecordConflictForTest()).toBeNull()
101
+ })
102
+
103
+ it('does NOT show the bar when the operation resolves successfully', async () => {
104
+ setupTriggerEventOk()
105
+ const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
106
+
107
+ await act(async () => {
108
+ const value = await result.current.runMutation({
109
+ operation: async () => 'ok',
110
+ context: {},
111
+ mutationPayload: {},
112
+ })
113
+ expect(value).toBe('ok')
114
+ })
115
+
116
+ expect(getRecordConflictForTest()).toBeNull()
117
+ })
118
+ })
@@ -4,6 +4,8 @@ import * as React from 'react'
4
4
  import { useInjectionSpotEvents } from './InjectionSpot'
5
5
  import { GLOBAL_MUTATION_INJECTION_SPOT_ID, dispatchBackendMutationError } from './mutationEvents'
6
6
  import { withScopedApiRequestHeaders } from '../utils/apiCall'
7
+ import { surfaceRecordConflict } from '../conflicts'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
9
 
8
10
  type GuardedMutationContext = Record<string, unknown>
9
11
 
@@ -30,6 +32,7 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
30
32
  blockedMessage = 'Save blocked by validation',
31
33
  spotId = GLOBAL_MUTATION_INJECTION_SPOT_ID,
32
34
  }: UseGuardedMutationOptions) {
35
+ const t = useT()
33
36
  const { triggerEvent } = useInjectionSpotEvents<TContext, Record<string, unknown>>(spotId)
34
37
  const lastMutationRef = React.useRef<StoredMutation<TContext> | null>(null)
35
38
 
@@ -39,7 +42,19 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
39
42
  formId: contextId,
40
43
  error,
41
44
  })
42
- }, [contextId])
45
+ // Default UX for OSS optimistic-lock conflicts (spec
46
+ // .ai/specs/2026-05-25-oss-optimistic-locking.md §3.6): when the
47
+ // server returns 409 with `code: 'optimistic_lock_conflict'`,
48
+ // surface the conflict on the unified, persistent, error-styled
49
+ // RecordConflictBanner (rendered in AppShell) instead of a transient
50
+ // toast. Callers that have their own handler still see the dispatched
51
+ // event above and can suppress/override by catching the error first.
52
+ try {
53
+ surfaceRecordConflict(error, t)
54
+ } catch {
55
+ // ignore conflict-bar dispatch failures
56
+ }
57
+ }, [contextId, t])
43
58
 
44
59
  const runMutation = React.useCallback(async <TResult,>({
45
60
  operation,
@@ -21,7 +21,9 @@ import {
21
21
  SelectTrigger,
22
22
  SelectValue,
23
23
  } from '../../primitives/select'
24
- import { apiCall } from '../utils/apiCall'
24
+ import { apiCall, withScopedApiRequestHeaders } from '../utils/apiCall'
25
+ import { buildOptimisticLockHeader } from '../utils/optimisticLock'
26
+ import { surfaceRecordConflict } from '../conflicts'
25
27
  import { flash } from '../FlashMessages'
26
28
  import { Page, PageBody } from '../Page'
27
29
  import { useBackendChrome } from '../BackendChromeProvider'
@@ -231,6 +233,9 @@ export function SidebarCustomizationEditor({
231
233
  const [addDialogError, setAddDialogError] = React.useState<string | null>(null)
232
234
  const baseSnapshotRef = React.useRef<SidebarGroup[] | null>(null)
233
235
  const hasInitializedRef = React.useRef(false)
236
+ // Tracks the user-scope sidebar-preference version so concurrent edits from another
237
+ // tab are caught by the server's optimistic-lock guard (409 → conflict bar).
238
+ const preferencesUpdatedAtRef = React.useRef<string | null>(null)
234
239
 
235
240
  const { runMutation, retryLastMutation } = useGuardedMutation<{
236
241
  formId: string
@@ -285,11 +290,12 @@ export function SidebarCustomizationEditor({
285
290
  }, [variantsApiPath])
286
291
 
287
292
  const loadRolesPayload = React.useCallback(async (): Promise<{ canApplyToRoles: boolean; roles: RoleTarget[] }> => {
288
- const call = await apiCall<{ canApplyToRoles?: boolean; roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }> }>(preferencesApiPath)
293
+ const call = await apiCall<{ canApplyToRoles?: boolean; roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>; updatedAt?: string | null }>(preferencesApiPath)
289
294
  if (!call.ok) {
290
295
  return { canApplyToRoles: false, roles: [] }
291
296
  }
292
297
  const data = call.result ?? null
298
+ preferencesUpdatedAtRef.current = typeof data?.updatedAt === 'string' ? data.updatedAt : null
293
299
  const can = data?.canApplyToRoles === true
294
300
  const roles = Array.isArray(data?.roles)
295
301
  ? (data!.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>)
@@ -695,19 +701,37 @@ export function SidebarCustomizationEditor({
695
701
  }
696
702
  const preferencesCall = await runMutation({
697
703
  operation: () =>
698
- apiCall(preferencesApiPath, {
699
- method: 'PUT',
700
- headers: { 'content-type': 'application/json' },
701
- body: JSON.stringify(preferencesPayload),
702
- }),
704
+ withScopedApiRequestHeaders(
705
+ buildOptimisticLockHeader(preferencesUpdatedAtRef.current),
706
+ () =>
707
+ apiCall<{ updatedAt?: string | null }>(preferencesApiPath, {
708
+ method: 'PUT',
709
+ headers: { 'content-type': 'application/json' },
710
+ body: JSON.stringify(preferencesPayload),
711
+ }),
712
+ ),
703
713
  context: buildMutationContext('savePreferences', selectedVariantId),
704
714
  mutationPayload: preferencesPayload,
705
715
  })
716
+ if (preferencesCall.ok) {
717
+ preferencesUpdatedAtRef.current =
718
+ typeof preferencesCall.result?.updatedAt === 'string' ? preferencesCall.result.updatedAt : null
719
+ }
706
720
  if (!preferencesCall.ok) {
707
- // The variant entity is the canonical layout; the preferences sync is what the
708
- // AppShell sidebar actually reads. A failed sync would leave the saved variant
709
- // not reflected live, so surface it as a save error rather than flashing success.
710
- setError(formatVariantApiError(preferencesCall, t))
721
+ // A stale save (another tab changed the sidebar customization first) returns a
722
+ // 409 with `code: 'optimistic_lock_conflict'`. apiCall does not throw, so surface
723
+ // the conflict explicitly on the shared, persistent conflict bar instead of a
724
+ // generic save error.
725
+ const surfaced = surfaceRecordConflict(
726
+ { status: preferencesCall.status, body: preferencesCall.result },
727
+ t,
728
+ )
729
+ if (!surfaced) {
730
+ // The variant entity is the canonical layout; the preferences sync is what the
731
+ // AppShell sidebar actually reads. A failed sync would leave the saved variant
732
+ // not reflected live, so surface it as a save error rather than flashing success.
733
+ setError(formatVariantApiError(preferencesCall, t))
734
+ }
711
735
  return
712
736
  }
713
737
  try { window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT)) } catch { /* no listener attached — fine, AppShell will refresh on next navigation */ }