@open-mercato/ui 0.9.0 → 0.9.1-develop.7462.1.94c09f158f

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.
@@ -60,6 +60,93 @@ describe('useProgressSse', () => {
60
60
  mockAppEventHandlers.clear()
61
61
  })
62
62
 
63
+ // Prior behavior (fixed): idle pages polled /api/progress/active every 5s
64
+ // regardless of whether any job was active. A characterization run against
65
+ // that code (1 mount fetch + 12 polls over 60s = 13 calls) confirmed the
66
+ // defect before this fix landed. The fix degrades the idle cadence to 30s
67
+ // (a reconciliation backstop for a dropped cross-process notify) rather
68
+ // than stopping outright, since the Postgres LISTEN/NOTIFY bridge has no
69
+ // replay for a listener that was mid-reconnect when a job was created.
70
+ it('idle with no active jobs: degrades to the 30s reconciliation cadence', async () => {
71
+ jest.useFakeTimers()
72
+ mockApiCall.mockResolvedValue(mockProgressResponse([]))
73
+
74
+ renderHook(() => useProgressSse())
75
+
76
+ await act(async () => {
77
+ await Promise.resolve()
78
+ })
79
+
80
+ expect(mockApiCall).toHaveBeenCalledTimes(1)
81
+
82
+ await act(async () => {
83
+ jest.advanceTimersByTime(60000)
84
+ await Promise.resolve()
85
+ })
86
+
87
+ // 1 mount fetch + 2 polls at 30s over 60s = 3.
88
+ expect(mockApiCall).toHaveBeenCalledTimes(3)
89
+ })
90
+
91
+ it('while a job is active: keeps polling at the 5s cadence', async () => {
92
+ jest.useFakeTimers()
93
+ mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
94
+
95
+ renderHook(() => useProgressSse())
96
+
97
+ await act(async () => {
98
+ await Promise.resolve()
99
+ })
100
+
101
+ expect(mockApiCall).toHaveBeenCalledTimes(1)
102
+
103
+ await act(async () => {
104
+ jest.advanceTimersByTime(60000)
105
+ await Promise.resolve()
106
+ })
107
+
108
+ // 1 mount fetch + 12 polls at 5s over 60s = 13.
109
+ expect(mockApiCall).toHaveBeenCalledTimes(13)
110
+ })
111
+
112
+ it('a progress.job.created event while idle triggers exactly one fetch', async () => {
113
+ mockApiCall.mockResolvedValue(mockProgressResponse([]))
114
+ renderHook(() => useProgressSse())
115
+
116
+ await waitFor(() => expect(mockApiCall).toHaveBeenCalledTimes(1))
117
+
118
+ act(() => {
119
+ for (const handler of mockAppEventHandlers.get('progress.job.created') ?? []) {
120
+ handler({})
121
+ }
122
+ })
123
+
124
+ await waitFor(() => expect(mockApiCall).toHaveBeenCalledTimes(2))
125
+ })
126
+
127
+ it('unmounting mid-interval stops further fetches', async () => {
128
+ jest.useFakeTimers()
129
+ mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
130
+
131
+ const { result, unmount } = renderHook(() => useProgressSse())
132
+
133
+ await act(async () => {
134
+ await Promise.resolve()
135
+ })
136
+
137
+ await waitFor(() => expect(result.current.activeJobs).toHaveLength(1))
138
+
139
+ const callsBeforeUnmount = mockApiCall.mock.calls.length
140
+ unmount()
141
+
142
+ await act(async () => {
143
+ jest.advanceTimersByTime(60000)
144
+ await Promise.resolve()
145
+ })
146
+
147
+ expect(mockApiCall).toHaveBeenCalledTimes(callsBeforeUnmount)
148
+ })
149
+
63
150
  it('removes a job from activeJobs when an SSE update carries a terminal status', async () => {
64
151
  mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
65
152
  const { result } = renderHook(() => useProgressSse())
@@ -79,6 +166,41 @@ describe('useProgressSse', () => {
79
166
  }))
80
167
  })
81
168
 
169
+ it('stays paused in a backgrounded tab across a hasActiveJobs transition', async () => {
170
+ jest.useFakeTimers()
171
+ mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
172
+
173
+ const { result } = renderHook(() => useProgressSse())
174
+
175
+ await act(async () => {
176
+ await Promise.resolve()
177
+ })
178
+ await waitFor(() => expect(result.current.activeJobs).toHaveLength(1))
179
+
180
+ Object.defineProperty(document, 'hidden', { value: true, configurable: true })
181
+ act(() => {
182
+ document.dispatchEvent(new Event('visibilitychange'))
183
+ })
184
+
185
+ const callsWhileHidden = mockApiCall.mock.calls.length
186
+
187
+ act(() => {
188
+ for (const handler of mockAppEventHandlers.get('progress.job.updated') ?? []) {
189
+ handler({ payload: { ...completedJob, jobId: completedJob.id } })
190
+ }
191
+ })
192
+ expect(result.current.activeJobs).toHaveLength(0)
193
+
194
+ await act(async () => {
195
+ jest.advanceTimersByTime(60000)
196
+ await Promise.resolve()
197
+ })
198
+
199
+ expect(mockApiCall).toHaveBeenCalledTimes(callsWhileHidden)
200
+
201
+ Object.defineProperty(document, 'hidden', { value: false, configurable: true })
202
+ })
203
+
82
204
  it('periodically reconciles active jobs in SSE mode when completion events are missed', async () => {
83
205
  jest.useFakeTimers()
84
206
  mockApiCall
@@ -54,6 +54,7 @@ export function ConfirmDialog({
54
54
  const [internalOpen, setInternalOpen] = React.useState(false);
55
55
  const cancelButtonRef = React.useRef<HTMLButtonElement>(null);
56
56
  const confirmButtonRef = React.useRef<HTMLButtonElement>(null);
57
+ const restoreAriaHiddenRef = React.useRef(false);
57
58
  const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null);
58
59
  // Unique IDs so multiple ConfirmDialog instances on the same page don't
59
60
  // collide and make `aria-labelledby` resolve to the wrong dialog's title.
@@ -111,10 +112,25 @@ export function ConfirmDialog({
111
112
  }
112
113
  }, 0);
113
114
  }
115
+ // A modal opened underneath (e.g. a Radix Dialog) marks every sibling in
116
+ // <body> `aria-hidden`, including this portalled element. Shown on top
117
+ // of that modal it must stay reachable for assistive technology.
118
+ if (dialog.getAttribute("aria-hidden") === "true") {
119
+ dialog.removeAttribute("aria-hidden");
120
+ restoreAriaHiddenRef.current = true;
121
+ }
114
122
  } else {
115
123
  if (dialog.open) {
116
124
  dialog.close();
117
125
  }
126
+ if (restoreAriaHiddenRef.current) {
127
+ restoreAriaHiddenRef.current = false;
128
+ // Hand the attribute back only while the modal that hid us is still
129
+ // open; its own cleanup has otherwise already removed the marker.
130
+ if (dialog.hasAttribute("data-aria-hidden")) {
131
+ dialog.setAttribute("aria-hidden", "true");
132
+ }
133
+ }
118
134
  }
119
135
  }, [open, resolvedCancelText, portalTarget]);
120
136
 
@@ -61,6 +61,35 @@ describe('ConfirmDialog', () => {
61
61
  expect(dialog.parentElement).toBe(document.body)
62
62
  })
63
63
 
64
+ it('stays exposed to assistive technology when a modal underneath hid its siblings', () => {
65
+ const renderDialog = (open: boolean) => (
66
+ <ConfirmDialog
67
+ open={open}
68
+ onOpenChange={() => undefined}
69
+ onConfirm={() => undefined}
70
+ title="Discard unsaved changes?"
71
+ confirmText="Discard"
72
+ cancelText="Cancel"
73
+ />
74
+ )
75
+ const { rerender } = renderWithProviders(renderDialog(false))
76
+ const dialog = document.body.querySelector('dialog')!
77
+ dialog.setAttribute('aria-hidden', 'true')
78
+ dialog.setAttribute('data-aria-hidden', 'true')
79
+
80
+ rerender(renderDialog(true))
81
+ expect(dialog).not.toHaveAttribute('aria-hidden')
82
+ expect(screen.getByRole('alertdialog')).toBe(dialog)
83
+
84
+ rerender(renderDialog(false))
85
+ expect(dialog).toHaveAttribute('aria-hidden', 'true')
86
+
87
+ rerender(renderDialog(true))
88
+ dialog.removeAttribute('data-aria-hidden')
89
+ rerender(renderDialog(false))
90
+ expect(dialog).not.toHaveAttribute('aria-hidden')
91
+ })
92
+
64
93
  it('renders the destructive confirmation as the solid variant', () => {
65
94
  renderWithProviders(
66
95
  <ConfirmDialog
@@ -551,6 +551,22 @@ export function PhoneNumberField({
551
551
  () => buildPhoneCountryOptions(countriesProp, t, locale),
552
552
  [countriesProp, t, locale],
553
553
  )
554
+ const countryItems = React.useMemo(
555
+ () => countryOptions.map(({ country: optionCountry, label }) => (
556
+ <SelectItem
557
+ key={`${optionCountry.iso2}-${optionCountry.dialCode}`}
558
+ value={optionCountry.iso2}
559
+ textValue={label}
560
+ >
561
+ <SelectItemLeading>
562
+ <span className="text-base leading-none">{optionCountry.flag}</span>
563
+ </SelectItemLeading>
564
+ <span className="flex-1 truncate">{label}</span>
565
+ <span className="ml-2 text-xs text-muted-foreground tabular-nums">{optionCountry.dialCode}</span>
566
+ </SelectItem>
567
+ )),
568
+ [countryOptions],
569
+ )
554
570
  const fallbackCountry = React.useMemo(
555
571
  () => (defaultCountryIso2 && findCountryByIso(defaultCountryIso2)) || DEFAULT_COUNTRY,
556
572
  [defaultCountryIso2],
@@ -723,17 +739,7 @@ export function PhoneNumberField({
723
739
  <span className={cn('text-base leading-none', size && 'inline-flex size-5 shrink-0 items-center justify-center text-xl [&_img]:size-full')} aria-hidden="true">{renderCountryIcon ? renderCountryIcon(country) : country.flag}</span>
724
740
  <span className={cn("text-sm text-foreground tabular-nums", disabled && "text-text-disabled")}>{country.dialCode}</span>
725
741
  </SelectTrigger>
726
- <SelectContent align="start">
727
- {countryOptions.map(({ country: c, label }) => (
728
- <SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2} textValue={label}>
729
- <SelectItemLeading>
730
- <span className="text-base leading-none">{c.flag}</span>
731
- </SelectItemLeading>
732
- <span className="flex-1 truncate">{label}</span>
733
- <span className="ml-2 text-xs text-muted-foreground tabular-nums">{c.dialCode}</span>
734
- </SelectItem>
735
- ))}
736
- </SelectContent>
742
+ <SelectContent align="start">{countryItems}</SelectContent>
737
743
  </Select>
738
744
  <div aria-hidden="true" className="w-px self-stretch bg-input" />
739
745
  <input
@@ -0,0 +1,35 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ const mockTranslate = (_key: string, fallback: string) => fallback
4
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
5
+ useT: () => mockTranslate,
6
+ useOptionalLocale: () => undefined,
7
+ }))
8
+
9
+ const mockItemRender = jest.fn()
10
+ jest.mock('../../../primitives/select', () => {
11
+ const actual = jest.requireActual('../../../primitives/select')
12
+ const ReactActual = jest.requireActual('react')
13
+ return {
14
+ ...actual,
15
+ SelectItem: ReactActual.forwardRef((props: Record<string, unknown>, ref: unknown) => {
16
+ mockItemRender()
17
+ return ReactActual.createElement(actual.SelectItem, { ...props, ref })
18
+ }),
19
+ }
20
+ })
21
+
22
+ import * as React from 'react'
23
+ import { render } from '@testing-library/react'
24
+ import { PHONE_COUNTRIES, PhoneNumberField } from '../PhoneNumberField'
25
+
26
+ describe('PhoneNumberField render cost', () => {
27
+ it('does not re-render its country options when the host form re-renders it', () => {
28
+ const view = render(<PhoneNumberField value="+48 600 100 200" onValueChange={() => {}} />)
29
+ expect(mockItemRender.mock.calls.length).toBeGreaterThanOrEqual(PHONE_COUNTRIES.length)
30
+ mockItemRender.mockClear()
31
+ view.rerender(<PhoneNumberField value="+48 600 100 200" onValueChange={() => {}} />)
32
+ view.rerender(<PhoneNumberField value="+48 600 100 201" onValueChange={() => {}} />)
33
+ expect(mockItemRender).not.toHaveBeenCalled()
34
+ })
35
+ })
@@ -7,6 +7,7 @@ import type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'
7
7
  import { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'
8
8
 
9
9
  const SSE_PROGRESS_SYNC_INTERVAL = 5000
10
+ const SSE_PROGRESS_IDLE_RECONCILE_INTERVAL = 30000
10
11
 
11
12
  function isVisibleProgressJob(job: ProgressJobDto): boolean {
12
13
  return job.meta?.hiddenFromTopBar !== true
@@ -69,10 +70,26 @@ export function useProgressSse(): UseProgressPollResult {
69
70
  void fetchJobs()
70
71
  }, [fetchJobs])
71
72
 
73
+ // The cross-process bridge (packages/events/src/bridge.ts) delivers
74
+ // broadcast events over Postgres LISTEN/NOTIFY, which has no replay: a
75
+ // notify published during a listener reconnect window is lost for good.
76
+ // Poll fast while a job is active, and fall back to a slow reconciliation
77
+ // poll while idle so a missed `progress.job.created` still surfaces.
78
+ // See `.ai/lessons/worker-emitted-progress-needs-polling-fallback-even.md`.
79
+ const hasActiveJobs = activeJobs.length > 0
80
+
72
81
  React.useEffect(() => {
73
- let interval: ReturnType<typeof setInterval> | null = setInterval(() => {
74
- void fetchJobs()
75
- }, SSE_PROGRESS_SYNC_INTERVAL)
82
+ let interval: ReturnType<typeof setInterval> | null = null
83
+
84
+ const startInterval = () => {
85
+ if (interval) clearInterval(interval)
86
+ if (document.hidden) return
87
+ interval = setInterval(() => {
88
+ void fetchJobs()
89
+ }, hasActiveJobs ? SSE_PROGRESS_SYNC_INTERVAL : SSE_PROGRESS_IDLE_RECONCILE_INTERVAL)
90
+ }
91
+
92
+ startInterval()
76
93
 
77
94
  const onVisibilityChange = () => {
78
95
  if (document.hidden) {
@@ -82,10 +99,7 @@ export function useProgressSse(): UseProgressPollResult {
82
99
  }
83
100
  } else {
84
101
  void fetchJobs()
85
- if (interval) clearInterval(interval)
86
- interval = setInterval(() => {
87
- void fetchJobs()
88
- }, SSE_PROGRESS_SYNC_INTERVAL)
102
+ startInterval()
89
103
  }
90
104
  }
91
105
 
@@ -94,7 +108,7 @@ export function useProgressSse(): UseProgressPollResult {
94
108
  if (interval) clearInterval(interval)
95
109
  document.removeEventListener('visibilitychange', onVisibilityChange)
96
110
  }
97
- }, [fetchJobs])
111
+ }, [fetchJobs, hasActiveJobs])
98
112
 
99
113
  React.useEffect(() => {
100
114
  return subscribeProgressUpdate((detail) => {