@open-mercato/ui 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e

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.
@@ -0,0 +1,555 @@
1
+ /** @jest-environment jsdom */
2
+ const pushMock = jest.fn()
3
+ const triggerEventMock = jest.fn()
4
+ const confirmDialogMock = jest.fn()
5
+ const flashMock = jest.fn()
6
+ const mockInjectedDataWidgets: unknown[] = []
7
+ let injectionWidgets: Array<{ moduleId: string; widgetId: string; key: string }> = []
8
+ let suspendedDisplayTitle: string | null = null
9
+ const displaySuspension = new Promise<never>(() => {})
10
+
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ push: pushMock }),
13
+ usePathname: () => '/',
14
+ useSearchParams: () => new URLSearchParams(),
15
+ }))
16
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
17
+ jest.mock('../confirm-dialog', () => ({
18
+ useConfirmDialog: () => ({ confirm: confirmDialogMock, ConfirmDialogElement: null }),
19
+ }))
20
+ jest.mock('../FlashMessages', () => ({
21
+ flash: (...args: unknown[]) => flashMock(...args),
22
+ }))
23
+ jest.mock('../injection/InjectionSpot', () => ({
24
+ __esModule: true,
25
+ InjectionSpot: ({ data }: { data?: Record<string, unknown> }) => {
26
+ if (suspendedDisplayTitle !== null && data?.title === suspendedDisplayTitle) {
27
+ throw displaySuspension
28
+ }
29
+ return null
30
+ },
31
+ useInjectionWidgets: () => ({ widgets: injectionWidgets, loading: false, error: null }),
32
+ useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
33
+ }))
34
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
35
+ __esModule: true,
36
+ useInjectionDataWidgets: () => ({ widgets: mockInjectedDataWidgets, isLoading: false, error: null }),
37
+ }))
38
+
39
+ import * as React from 'react'
40
+ import { act, fireEvent, waitFor } from '@testing-library/react'
41
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
42
+ import { CrudForm, type CrudField } from '../CrudForm'
43
+
44
+ describe('CrudForm asynchronous injection display transforms', () => {
45
+ beforeEach(() => {
46
+ injectionWidgets = []
47
+ suspendedDisplayTitle = null
48
+ confirmDialogMock.mockReset()
49
+ confirmDialogMock.mockResolvedValue(false)
50
+ flashMock.mockReset()
51
+ triggerEventMock.mockReset()
52
+ triggerEventMock.mockImplementation(async (event: string, data: Record<string, unknown>) => ({
53
+ data: event === 'transformDisplayData' && injectionWidgets.length > 0
54
+ ? { ...data, title: String(data.title).toUpperCase() }
55
+ : data,
56
+ ok: true,
57
+ }))
58
+ })
59
+
60
+ it('reapplies initial display data when an injection widget loads after the record', async () => {
61
+ const fields: CrudField[] = [{ id: 'title', label: 'Title', type: 'text' }]
62
+ const renderForm = () => (
63
+ <CrudForm
64
+ title="Form"
65
+ fields={fields}
66
+ initialValues={{ title: 'display me' }}
67
+ injectionSpotId="example:async-widget"
68
+ onSubmit={() => {}}
69
+ />
70
+ )
71
+ const rendered = renderWithProviders(renderForm(), {
72
+ dict: { 'ui.forms.actions.save': 'Save' },
73
+ })
74
+ const input = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
75
+
76
+ await act(async () => {
77
+ await Promise.resolve()
78
+ })
79
+ expect(input).toHaveValue('display me')
80
+
81
+ injectionWidgets = [{ moduleId: 'example', widgetId: 'example.async-widget', key: 'example:async-widget' }]
82
+ rendered.rerender(renderForm())
83
+ await waitFor(() => {
84
+ expect(triggerEventMock.mock.calls.filter(([event]) => event === 'transformDisplayData')).toHaveLength(2)
85
+ })
86
+ await waitFor(() => {
87
+ expect(triggerEventMock).toHaveBeenCalledWith(
88
+ 'transformDisplayData',
89
+ expect.objectContaining({ title: 'display me' }),
90
+ expect.anything(),
91
+ )
92
+ expect(input).toHaveValue('DISPLAY ME')
93
+ })
94
+ })
95
+
96
+ it('does not commit a dirty baseline from an interrupted display transform', async () => {
97
+ let resolveTransform: ((result: { ok: true; data: Record<string, unknown> }) => void) | null = null
98
+ injectionWidgets = [{ moduleId: 'example', widgetId: 'example.async-widget', key: 'example:async-widget' }]
99
+ triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>) => {
100
+ if (event !== 'transformDisplayData') return Promise.resolve({ ok: true, data })
101
+ return new Promise<{ ok: true; data: Record<string, unknown> }>((resolve) => {
102
+ resolveTransform = resolve
103
+ })
104
+ })
105
+ const fields: CrudField[] = [{ id: 'title', label: 'Title', type: 'text' }]
106
+ const rendered = renderWithProviders(
107
+ <React.Suspense fallback={<div data-testid="display-transform-suspended" />}>
108
+ <CrudForm
109
+ title="Form"
110
+ fields={fields}
111
+ initialValues={{ title: 'display me' }}
112
+ injectionSpotId="example:async-widget"
113
+ onSubmit={() => {}}
114
+ />
115
+ </React.Suspense>,
116
+ {
117
+ dict: {
118
+ 'ui.forms.actions.save': 'Save',
119
+ 'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
120
+ },
121
+ },
122
+ )
123
+ const input = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
124
+
125
+ await waitFor(() => expect(resolveTransform).not.toBeNull())
126
+ suspendedDisplayTitle = 'DISPLAY ME'
127
+ await act(async () => {
128
+ resolveTransform?.({ ok: true, data: { title: 'DISPLAY ME' } })
129
+ await Promise.resolve()
130
+ })
131
+ await waitFor(() => expect(rendered.getByTestId('display-transform-suspended')).toBeInTheDocument())
132
+
133
+ suspendedDisplayTitle = null
134
+ await act(async () => {
135
+ fireEvent.change(input, { target: { value: 'DISPLAY ME' } })
136
+ await Promise.resolve()
137
+ })
138
+ await waitFor(() => expect(input).toHaveValue('DISPLAY ME'))
139
+
140
+ const anchor = document.createElement('a')
141
+ anchor.href = '/other'
142
+ document.body.appendChild(anchor)
143
+ const navigationEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
144
+ await act(async () => {
145
+ anchor.dispatchEvent(navigationEvent)
146
+ await Promise.resolve()
147
+ })
148
+
149
+ expect(confirmDialogMock).toHaveBeenCalled()
150
+ expect(navigationEvent.defaultPrevented).toBe(true)
151
+ anchor.remove()
152
+ })
153
+
154
+ it('composes an async widget side effect with a user edit queued in the same batch', async () => {
155
+ const noteEventPayloads: Record<string, unknown>[] = []
156
+ let resolveFieldChange: ((result: {
157
+ ok: true
158
+ fieldChange: { sideEffects: Record<string, unknown> }
159
+ }) => void) | null = null
160
+ triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: { fieldId?: string }) => {
161
+ if (event === 'onFieldChange' && meta?.fieldId === 'title') {
162
+ return new Promise((resolve) => {
163
+ resolveFieldChange = resolve
164
+ })
165
+ }
166
+ if (event === 'onFieldChange' && meta?.fieldId === 'note') {
167
+ noteEventPayloads.push(data)
168
+ }
169
+ return Promise.resolve({ ok: true, data })
170
+ })
171
+ const fields: CrudField[] = [
172
+ { id: 'title', label: 'Title', type: 'text' },
173
+ { id: 'widgetValue', label: 'Widget value', type: 'text' },
174
+ { id: 'note', label: 'Note', type: 'text' },
175
+ ]
176
+ const rendered = renderWithProviders(
177
+ <CrudForm
178
+ title="Form"
179
+ fields={fields}
180
+ initialValues={{ title: 'Original', widgetValue: '', note: '' }}
181
+ injectionSpotId="example:async-widget"
182
+ onSubmit={() => {}}
183
+ />,
184
+ { dict: { 'ui.forms.actions.save': 'Save' } },
185
+ )
186
+ const titleInput = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
187
+ const widgetInput = rendered.container.querySelector('[data-crud-field-id="widgetValue"] input') as HTMLInputElement
188
+ const noteInput = rendered.container.querySelector('[data-crud-field-id="note"] input') as HTMLInputElement
189
+
190
+ fireEvent.change(titleInput, { target: { value: 'Changed' } })
191
+ await waitFor(() => expect(resolveFieldChange).not.toBeNull())
192
+
193
+ await act(async () => {
194
+ resolveFieldChange?.({
195
+ ok: true,
196
+ fieldChange: { sideEffects: { widgetValue: 'from widget' } },
197
+ })
198
+ await Promise.resolve()
199
+ fireEvent.change(noteInput, { target: { value: 'from user' } })
200
+ })
201
+
202
+ expect(widgetInput).toHaveValue('from widget')
203
+ expect(noteInput).toHaveValue('from user')
204
+ await waitFor(() => {
205
+ expect(noteEventPayloads).toContainEqual(expect.objectContaining({
206
+ widgetValue: 'from widget',
207
+ note: 'from user',
208
+ }))
209
+ })
210
+ })
211
+
212
+ it('drops a slow field result after a newer revision and dispatches the newer event with matching data', async () => {
213
+ let resolveFirstChange: ((result: {
214
+ ok: true
215
+ fieldChange: { value: string }
216
+ }) => void) | null = null
217
+ const secondChangePayloads: Array<{
218
+ data: Record<string, unknown>
219
+ fieldValue: unknown
220
+ }> = []
221
+ triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: {
222
+ fieldId?: string
223
+ fieldValue?: unknown
224
+ }) => {
225
+ if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
226
+ if (meta?.fieldValue === 'A') {
227
+ return new Promise((resolve) => {
228
+ resolveFirstChange = resolve
229
+ })
230
+ }
231
+ if (meta?.fieldValue === 'B') {
232
+ secondChangePayloads.push({ data, fieldValue: meta.fieldValue })
233
+ return Promise.resolve({ ok: true, fieldChange: { value: 'B' } })
234
+ }
235
+ return Promise.resolve({ ok: true })
236
+ })
237
+ const rendered = renderWithProviders(
238
+ <CrudForm
239
+ title="Form"
240
+ fields={[{ id: 'title', label: 'Title', type: 'text' }]}
241
+ initialValues={{ title: 'Original' }}
242
+ injectionSpotId="example:async-widget"
243
+ onSubmit={() => {}}
244
+ />,
245
+ { dict: { 'ui.forms.actions.save': 'Save' } },
246
+ )
247
+ const titleInput = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
248
+
249
+ fireEvent.change(titleInput, { target: { value: 'A' } })
250
+ await waitFor(() => expect(resolveFirstChange).not.toBeNull())
251
+ fireEvent.change(titleInput, { target: { value: 'B' } })
252
+ expect(titleInput).toHaveValue('B')
253
+
254
+ await act(async () => {
255
+ resolveFirstChange?.({ ok: true, fieldChange: { value: 'A' } })
256
+ await Promise.resolve()
257
+ })
258
+
259
+ await waitFor(() => {
260
+ expect(secondChangePayloads).toContainEqual({
261
+ data: expect.objectContaining({ title: 'B' }),
262
+ fieldValue: 'B',
263
+ })
264
+ })
265
+ expect(titleInput).toHaveValue('B')
266
+ })
267
+
268
+ it('does not apply a slow field side effect to a target edited after dispatch', async () => {
269
+ let resolveTitleChange: ((result: {
270
+ ok: true
271
+ fieldChange: { value: string; sideEffects: Record<string, unknown> }
272
+ }) => void) | null = null
273
+ let resolveNoteChange: ((result: {
274
+ ok: true
275
+ fieldChange: { value: string }
276
+ }) => void) | null = null
277
+ const noteEventPayloads: Record<string, unknown>[] = []
278
+ triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: {
279
+ fieldId?: string
280
+ }) => {
281
+ if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
282
+ if (meta?.fieldId === 'title') {
283
+ return new Promise((resolve) => {
284
+ resolveTitleChange = resolve
285
+ })
286
+ }
287
+ if (meta?.fieldId === 'note') {
288
+ noteEventPayloads.push(data)
289
+ return new Promise((resolve) => {
290
+ resolveNoteChange = resolve
291
+ })
292
+ }
293
+ return Promise.resolve({ ok: true })
294
+ })
295
+ const rendered = renderWithProviders(
296
+ <CrudForm
297
+ title="Form"
298
+ fields={[
299
+ { id: 'title', label: 'Title', type: 'text' },
300
+ { id: 'note', label: 'Note', type: 'text' },
301
+ ]}
302
+ initialValues={{ title: 'Original', note: '' }}
303
+ injectionSpotId="example:async-widget"
304
+ onSubmit={() => {}}
305
+ />,
306
+ { dict: { 'ui.forms.actions.save': 'Save' } },
307
+ )
308
+ const getTitleInput = () => rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
309
+ const getNoteInput = () => rendered.container.querySelector('[data-crud-field-id="note"] input') as HTMLInputElement
310
+
311
+ fireEvent.change(getTitleInput(), { target: { value: 'Changed title' } })
312
+ await waitFor(() => expect(resolveTitleChange).not.toBeNull())
313
+ fireEvent.change(getNoteInput(), { target: { value: 'User note' } })
314
+
315
+ await act(async () => {
316
+ resolveTitleChange?.({
317
+ ok: true,
318
+ fieldChange: {
319
+ value: 'Normalized title',
320
+ sideEffects: { note: 'Stale widget note' },
321
+ },
322
+ })
323
+ await Promise.resolve()
324
+ })
325
+
326
+ await waitFor(() => expect(resolveNoteChange).not.toBeNull())
327
+ expect(getNoteInput()).toHaveValue('User note')
328
+ expect(noteEventPayloads).toContainEqual(expect.objectContaining({
329
+ title: 'Normalized title',
330
+ note: 'User note',
331
+ }))
332
+
333
+ await act(async () => {
334
+ resolveNoteChange?.({ ok: true, fieldChange: { value: 'User note' } })
335
+ await Promise.resolve()
336
+ })
337
+ expect(getNoteInput()).toHaveValue('User note')
338
+ })
339
+
340
+ it('does not apply a queued field side effect to a target edited before dispatch', async () => {
341
+ let resolveBlockingChange: ((result: {
342
+ ok: true
343
+ fieldChange: { value: string }
344
+ }) => void) | null = null
345
+ let resolveQueuedTitleChange: ((result: {
346
+ ok: true
347
+ fieldChange: { value: string; sideEffects: Record<string, unknown> }
348
+ }) => void) | null = null
349
+ const queuedTitlePayloads: Record<string, unknown>[] = []
350
+ const notePayloads: Record<string, unknown>[] = []
351
+ triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: {
352
+ fieldId?: string
353
+ fieldValue?: unknown
354
+ }) => {
355
+ if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
356
+ if (meta?.fieldId === 'blocker') {
357
+ return new Promise((resolve) => {
358
+ resolveBlockingChange = resolve
359
+ })
360
+ }
361
+ if (meta?.fieldId === 'title') {
362
+ queuedTitlePayloads.push(data)
363
+ return new Promise((resolve) => {
364
+ resolveQueuedTitleChange = resolve
365
+ })
366
+ }
367
+ if (meta?.fieldId === 'note') {
368
+ notePayloads.push(data)
369
+ return Promise.resolve({ ok: true, fieldChange: { value: meta.fieldValue } })
370
+ }
371
+ return Promise.resolve({ ok: true })
372
+ })
373
+ const rendered = renderWithProviders(
374
+ <CrudForm
375
+ title="Form"
376
+ fields={[
377
+ { id: 'blocker', label: 'Blocker', type: 'text' },
378
+ { id: 'title', label: 'Title', type: 'text' },
379
+ { id: 'note', label: 'Note', type: 'text' },
380
+ ]}
381
+ initialValues={{ blocker: '', title: '', note: '' }}
382
+ injectionSpotId="example:async-widget"
383
+ onSubmit={() => {}}
384
+ />,
385
+ { dict: { 'ui.forms.actions.save': 'Save' } },
386
+ )
387
+ const getInput = (fieldId: string) => rendered.container.querySelector(
388
+ `[data-crud-field-id="${fieldId}"] input`,
389
+ ) as HTMLInputElement
390
+
391
+ fireEvent.change(getInput('blocker'), { target: { value: 'X' } })
392
+ await waitFor(() => expect(resolveBlockingChange).not.toBeNull())
393
+ fireEvent.change(getInput('title'), { target: { value: 'A' } })
394
+ fireEvent.change(getInput('note'), { target: { value: 'User note' } })
395
+
396
+ await act(async () => {
397
+ resolveBlockingChange?.({ ok: true, fieldChange: { value: 'X' } })
398
+ await Promise.resolve()
399
+ })
400
+ await waitFor(() => expect(resolveQueuedTitleChange).not.toBeNull())
401
+ expect(queuedTitlePayloads).toContainEqual(expect.objectContaining({
402
+ title: 'A',
403
+ note: 'User note',
404
+ }))
405
+
406
+ await act(async () => {
407
+ resolveQueuedTitleChange?.({
408
+ ok: true,
409
+ fieldChange: {
410
+ value: 'A normalized',
411
+ sideEffects: { note: 'Stale queued note' },
412
+ },
413
+ })
414
+ await Promise.resolve()
415
+ })
416
+
417
+ await waitFor(() => expect(notePayloads).toHaveLength(1))
418
+ expect(getInput('note')).toHaveValue('User note')
419
+ expect(notePayloads).toContainEqual(expect.objectContaining({
420
+ title: 'A normalized',
421
+ note: 'User note',
422
+ }))
423
+ })
424
+
425
+ it('invalidates queued and in-flight field events when the initial record generation changes', async () => {
426
+ let resolveOldChange: ((result: {
427
+ ok: true
428
+ fieldChange: {
429
+ value: string
430
+ sideEffects: Record<string, unknown>
431
+ messages: Array<{ text: string; severity: 'error' }>
432
+ }
433
+ }) => void) | null = null
434
+ let queuedOldChangeCalls = 0
435
+ const freshChangePayloads: Record<string, unknown>[] = []
436
+ triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: {
437
+ fieldValue?: unknown
438
+ }) => {
439
+ if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
440
+ if (meta?.fieldValue === 'old-active') {
441
+ return new Promise((resolve) => {
442
+ resolveOldChange = resolve
443
+ })
444
+ }
445
+ if (meta?.fieldValue === 'old-queued') {
446
+ queuedOldChangeCalls += 1
447
+ return Promise.resolve({ ok: true })
448
+ }
449
+ if (meta?.fieldValue === 'fresh') {
450
+ freshChangePayloads.push(data)
451
+ return Promise.resolve({
452
+ ok: true,
453
+ fieldChange: {
454
+ sideEffects: { marker: 'fresh marker' },
455
+ messages: [{ text: 'fresh message', severity: 'info' }],
456
+ },
457
+ })
458
+ }
459
+ return Promise.resolve({ ok: true })
460
+ })
461
+ const fields: CrudField[] = [
462
+ { id: 'title', label: 'Title', type: 'text' },
463
+ { id: 'note', label: 'Note', type: 'text' },
464
+ { id: 'marker', label: 'Marker', type: 'text' },
465
+ ]
466
+ const renderForm = (initialValues: Record<string, unknown>) => (
467
+ <CrudForm
468
+ title="Form"
469
+ fields={fields}
470
+ initialValues={initialValues}
471
+ injectionSpotId="example:async-widget"
472
+ onSubmit={() => {}}
473
+ />
474
+ )
475
+ const rendered = renderWithProviders(
476
+ renderForm({ id: 'record-1', title: 'First', note: '', marker: 'first marker' }),
477
+ { dict: { 'ui.forms.actions.save': 'Save' } },
478
+ )
479
+ const titleInput = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
480
+ const noteInput = rendered.container.querySelector('[data-crud-field-id="note"] input') as HTMLInputElement
481
+ const markerInput = rendered.container.querySelector('[data-crud-field-id="marker"] input') as HTMLInputElement
482
+
483
+ fireEvent.change(titleInput, { target: { value: 'old-active' } })
484
+ await waitFor(() => expect(resolveOldChange).not.toBeNull())
485
+ fireEvent.change(noteInput, { target: { value: 'old-queued' } })
486
+
487
+ rendered.rerender(renderForm({ id: 'record-2', title: 'Second', note: '', marker: 'new marker' }))
488
+ await waitFor(() => expect(markerInput).toHaveValue('new marker'))
489
+
490
+ fireEvent.change(noteInput, { target: { value: 'fresh' } })
491
+ await waitFor(() => expect(freshChangePayloads).toHaveLength(1))
492
+ await waitFor(() => expect(markerInput).toHaveValue('fresh marker'))
493
+
494
+ await act(async () => {
495
+ resolveOldChange?.({
496
+ ok: true,
497
+ fieldChange: {
498
+ value: 'stale result',
499
+ sideEffects: { marker: 'stale marker' },
500
+ messages: [{ text: 'stale message', severity: 'error' }],
501
+ },
502
+ })
503
+ await Promise.resolve()
504
+ })
505
+
506
+ expect(queuedOldChangeCalls).toBe(0)
507
+ expect(markerInput).toHaveValue('fresh marker')
508
+ expect(flashMock).toHaveBeenCalledWith('fresh message', 'info')
509
+ expect(flashMock).not.toHaveBeenCalledWith('stale message', 'error')
510
+ })
511
+
512
+ it('ignores an in-flight field result after unmount', async () => {
513
+ let resolveFieldChange: ((result: {
514
+ ok: true
515
+ fieldChange: {
516
+ sideEffects: Record<string, unknown>
517
+ messages: Array<{ text: string; severity: 'error' }>
518
+ }
519
+ }) => void) | null = null
520
+ triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>) => {
521
+ if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
522
+ return new Promise((resolve) => {
523
+ resolveFieldChange = resolve
524
+ })
525
+ })
526
+ const rendered = renderWithProviders(
527
+ <CrudForm
528
+ title="Form"
529
+ fields={[{ id: 'title', label: 'Title', type: 'text' }]}
530
+ initialValues={{ title: 'Original' }}
531
+ injectionSpotId="example:async-widget"
532
+ onSubmit={() => {}}
533
+ />,
534
+ { dict: { 'ui.forms.actions.save': 'Save' } },
535
+ )
536
+ const titleInput = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
537
+
538
+ fireEvent.change(titleInput, { target: { value: 'pending' } })
539
+ await waitFor(() => expect(resolveFieldChange).not.toBeNull())
540
+ rendered.unmount()
541
+
542
+ await act(async () => {
543
+ resolveFieldChange?.({
544
+ ok: true,
545
+ fieldChange: {
546
+ sideEffects: { title: 'stale result' },
547
+ messages: [{ text: 'stale message', severity: 'error' }],
548
+ },
549
+ })
550
+ await Promise.resolve()
551
+ })
552
+
553
+ expect(flashMock).not.toHaveBeenCalledWith('stale message', 'error')
554
+ })
555
+ })
@@ -139,7 +139,6 @@ export function useInjectionWidgets<TContext = unknown>(
139
139
  [payload?.grantedFeatures],
140
140
  )
141
141
  const hasBackendChromePayload = payload !== null
142
-
143
142
  React.useEffect(() => {
144
143
  contextRef.current = options?.context
145
144
  triggerOnLoadRef.current = options?.triggerOnLoad
@@ -304,6 +303,15 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
304
303
  [payload?.grantedFeatures],
305
304
  )
306
305
  const hasBackendChromePayload = payload !== null
306
+ const prefetchedEventWidgets = React.useMemo(() => {
307
+ if (prefetchedWidgets === undefined || !backendChromeReady) return []
308
+ return filterWidgetsByGrantedFeatures(
309
+ prefetchedWidgets,
310
+ grantedFeatureList,
311
+ hasBackendChromePayload,
312
+ )
313
+ }, [backendChromeReady, grantedFeatureList, hasBackendChromePayload, prefetchedWidgets])
314
+ const eventWidgets = prefetchedWidgets === undefined ? widgets : prefetchedEventWidgets
307
315
 
308
316
  React.useEffect(() => {
309
317
  return subscribeToInjectionRegistryChanges(() => {
@@ -429,7 +437,7 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
429
437
  if (TRANSFORMER_EVENTS.has(event)) {
430
438
  let pipelineData = data
431
439
  let applyToForm = false
432
- for (const widget of widgets) {
440
+ for (const widget of eventWidgets) {
433
441
  const handler = widget.module.eventHandlers?.[event]
434
442
  if (!handler) continue
435
443
  try {
@@ -467,7 +475,7 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
467
475
  let fieldSideEffects: Record<string, unknown> | undefined
468
476
  let fieldMessages: Array<{ text: string; severity: 'info' | 'warning' | 'error' }> | undefined
469
477
 
470
- for (const widget of widgets) {
478
+ for (const widget of eventWidgets) {
471
479
  const eventHandlers = widget.module.eventHandlers
472
480
  // Check operation filter — skip widget if current operation is filtered out
473
481
  const operationFilter = eventHandlers?.filter?.operations
@@ -566,8 +574,8 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
566
574
  }
567
575
  return { ok: true }
568
576
  },
569
- [widgets]
577
+ [eventWidgets]
570
578
  )
571
579
 
572
- return { triggerEvent, widgets }
580
+ return { triggerEvent, widgets: eventWidgets }
573
581
  }
@@ -210,6 +210,32 @@ describe('Injection widget feature gating', () => {
210
210
  expect(onBeforeSaveHiddenMock).not.toHaveBeenCalled()
211
211
  })
212
212
 
213
+ it('dispatches a newly prefetched lifecycle widget in the same layout commit', async () => {
214
+ const onBeforeSaveMock = jest.fn()
215
+ const prefetchedWidget = loadedSpotWidget(
216
+ lifecycleWidget('visible-lifecycle-widget', undefined, onBeforeSaveMock),
217
+ )
218
+ let layoutDispatches = 0
219
+
220
+ function Harness({ prefetched }: { prefetched: LoadedInjectionSpotWidget[] }) {
221
+ const { triggerEvent } = useInjectionSpotEvents<Record<string, unknown>, Record<string, unknown>>(
222
+ SPOT_ID,
223
+ prefetched,
224
+ )
225
+ React.useLayoutEffect(() => {
226
+ layoutDispatches += 1
227
+ void triggerEvent('onBeforeSave', {}, {})
228
+ }, [prefetched, triggerEvent])
229
+ return null
230
+ }
231
+
232
+ const rendered = render(<Harness prefetched={[]} />)
233
+ rendered.rerender(<Harness prefetched={[prefetchedWidget]} />)
234
+
235
+ await waitFor(() => expect(onBeforeSaveMock).toHaveBeenCalledTimes(1))
236
+ expect(layoutDispatches).toBe(2)
237
+ })
238
+
213
239
  it('filters lifecycle widgets loaded directly by metadata features', async () => {
214
240
  const onBeforeSaveHiddenMock = jest.fn()
215
241
  let resolveWidgets: (widgets: LoadedInjectionWidget[]) => void = () => {}