@open-mercato/ui 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

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 (67) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/records/ActivityCard.js +4 -3
  3. package/dist/ai/records/ActivityCard.js.map +2 -2
  4. package/dist/ai/records/DealCard.js +4 -3
  5. package/dist/ai/records/DealCard.js.map +2 -2
  6. package/dist/ai/records/ProductCard.js +3 -2
  7. package/dist/ai/records/ProductCard.js.map +2 -2
  8. package/dist/backend/AppShell.js +129 -106
  9. package/dist/backend/AppShell.js.map +2 -2
  10. package/dist/backend/DataTable.js +20 -7
  11. package/dist/backend/DataTable.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +26 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/icons/lucideRegistry.generated.js +4 -0
  15. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  16. package/dist/backend/injection/useRegisteredComponent.js +24 -6
  17. package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
  18. package/dist/backend/inputs/ComboboxInput.js +8 -4
  19. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +8 -8
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js +36 -30
  23. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  24. package/dist/backend/utils/crud.js.map +2 -2
  25. package/dist/primitives/alert.js +1 -1
  26. package/dist/primitives/alert.js.map +2 -2
  27. package/dist/primitives/icon-button.js +20 -1
  28. package/dist/primitives/icon-button.js.map +2 -2
  29. package/dist/primitives/pagination.js +14 -9
  30. package/dist/primitives/pagination.js.map +2 -2
  31. package/dist/primitives/rich-editor.js +14 -7
  32. package/dist/primitives/rich-editor.js.map +2 -2
  33. package/dist/utils/format.js +5 -5
  34. package/dist/utils/format.js.map +2 -2
  35. package/package.json +5 -4
  36. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +2 -0
  37. package/src/ai/records/ActivityCard.tsx +4 -3
  38. package/src/ai/records/DealCard.tsx +4 -3
  39. package/src/ai/records/ProductCard.tsx +3 -2
  40. package/src/backend/AppShell.tsx +89 -58
  41. package/src/backend/DataTable.tsx +44 -10
  42. package/src/backend/__tests__/AppShell.test.tsx +162 -1
  43. package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
  44. package/src/backend/__tests__/DataTable.headerWrap.test.tsx +111 -0
  45. package/src/backend/__tests__/DataTable.render.test.tsx +37 -0
  46. package/src/backend/__tests__/NotesSection.test.tsx +89 -0
  47. package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
  48. package/src/backend/detail/NotesSection.tsx +92 -67
  49. package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
  50. package/src/backend/injection/useRegisteredComponent.tsx +49 -8
  51. package/src/backend/inputs/ComboboxInput.tsx +21 -5
  52. package/src/backend/inputs/LookupSelect.tsx +8 -8
  53. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +142 -0
  54. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
  55. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +14 -11
  56. package/src/backend/utils/crud.ts +2 -0
  57. package/src/primitives/__tests__/alert.test.tsx +41 -1
  58. package/src/primitives/__tests__/icon-button.test.tsx +70 -0
  59. package/src/primitives/__tests__/notification.test.tsx +1 -1
  60. package/src/primitives/__tests__/pagination.test.tsx +54 -0
  61. package/src/primitives/__tests__/rich-editor.test.tsx +43 -0
  62. package/src/primitives/alert.tsx +5 -2
  63. package/src/primitives/icon-button.tsx +20 -1
  64. package/src/primitives/pagination.tsx +40 -9
  65. package/src/primitives/rich-editor.tsx +9 -4
  66. package/src/utils/__tests__/format.test.ts +27 -14
  67. package/src/utils/format.ts +10 -5
@@ -0,0 +1,122 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { fireEvent, render, screen, within } from '@testing-library/react'
4
+ import { DataTable } from '../DataTable'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
8
+
9
+ jest.mock('next/navigation', () => ({
10
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
11
+ }))
12
+
13
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
14
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
15
+ }))
16
+
17
+ /**
18
+ * The capped-count feature's risky half lives in the pager: `Pagination` decides
19
+ * from `totalIsCapped` plus a `hasNextPage` that `DataTable` derives from the
20
+ * rendered row count whether a row past the cap is reachable at all. The unit
21
+ * tests on the primitive pin its own logic; these pin the wiring between a
22
+ * capped list payload and that primitive, which is where a regression would
23
+ * silently strand data while every other test stayed green.
24
+ *
25
+ * Derived from the manual UI QA on #5228: 31 rows behind a reported total of 3.
26
+ */
27
+ type Row = { id: string; name: string }
28
+
29
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
30
+
31
+ const PAGE_SIZE = 20
32
+
33
+ function rows(count: number, offset = 0): Row[] {
34
+ return Array.from({ length: count }, (_, i) => ({
35
+ id: `row-${offset + i + 1}`,
36
+ name: `Row ${offset + i + 1}`,
37
+ }))
38
+ }
39
+
40
+ function renderTable(pagination: Record<string, unknown>, data: Row[]) {
41
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0, retry: false } } })
42
+ return render(
43
+ <QueryClientProvider client={queryClient}>
44
+ <I18nProvider locale="en" dict={{}}>
45
+ <DataTable columns={columns as any} data={data} pagination={pagination as any} />
46
+ </I18nProvider>
47
+ </QueryClientProvider>,
48
+ )
49
+ }
50
+
51
+ const info = () => document.querySelector('[data-slot="pagination-info"]')?.textContent ?? ''
52
+ const next = () => document.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement | null
53
+ const last = () => document.querySelector('[data-slot="pagination-last"]')
54
+
55
+ describe('DataTable pagination with a capped total', () => {
56
+ // The floor must read as a floor. A capped list showing "of 3 results" would
57
+ // state a number the server explicitly refused to vouch for.
58
+ it('renders the total as a floor and hides the jump-to-last control', () => {
59
+ renderTable(
60
+ { page: 1, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange: jest.fn() },
61
+ rows(PAGE_SIZE),
62
+ )
63
+ expect(info()).toBe('Showing 1 to 20 of 3+ results')
64
+ // The jump would land on the floor's last page while presenting itself as
65
+ // the end of the data, which is precisely the lie this feature must avoid.
66
+ expect(last()).toBeNull()
67
+ })
68
+
69
+ // This is the assertion that would have caught an unfixed clamp: with a
70
+ // floor-derived page count of 1, a naive pager disables Next on page 1 and
71
+ // every row past the cap becomes unreachable.
72
+ it('keeps Next live past the floor while the page comes back full', () => {
73
+ const onPageChange = jest.fn()
74
+ renderTable(
75
+ { page: 1, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange },
76
+ rows(PAGE_SIZE),
77
+ )
78
+ const button = next()
79
+ expect(button?.disabled).toBe(false)
80
+ fireEvent.click(button!)
81
+ expect(onPageChange).toHaveBeenCalledWith(2)
82
+ })
83
+
84
+ // Deep-linking past the floor must not bounce the user back, and the range
85
+ // label has to describe the rows actually on screen rather than the floor.
86
+ it('serves a page past the floor without snapping back, and retires Next on a short page', () => {
87
+ const onPageChange = jest.fn()
88
+ renderTable(
89
+ { page: 2, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange },
90
+ rows(11, PAGE_SIZE),
91
+ )
92
+ expect(info()).toBe('Showing 21 to 31 of 3+ results')
93
+ expect(within(document.body).getByText('Row 31')).toBeInTheDocument()
94
+ // Short page ⇒ nothing beyond it, so Next retires here rather than at the floor.
95
+ expect(next()?.disabled).toBe(true)
96
+ expect(onPageChange).not.toHaveBeenCalled()
97
+ })
98
+
99
+ // Capping is conditional: below the cap the server sends no flag, and the
100
+ // table must be byte-identical to its pre-feature behaviour.
101
+ it('leaves an uncapped list unchanged — exact total, jump-to-last offered', () => {
102
+ renderTable(
103
+ { page: 1, pageSize: PAGE_SIZE, total: 31, totalPages: 2, onPageChange: jest.fn() },
104
+ rows(PAGE_SIZE),
105
+ )
106
+ expect(info()).toBe('Showing 1 to 20 of 31 results')
107
+ expect(last()).not.toBeNull()
108
+ expect(next()?.disabled).toBe(false)
109
+ })
110
+
111
+ // The exact-multiple false positive: short-page detection cannot distinguish
112
+ // "a full last page" from "more to come", so Next survives one page too far.
113
+ // The label must not then claim a row it is not showing.
114
+ it('claims no range when a capped page comes back empty', () => {
115
+ renderTable(
116
+ { page: 3, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange: jest.fn() },
117
+ [],
118
+ )
119
+ expect(info()).toBe('No further results past 3')
120
+ expect(next()?.disabled).toBe(true)
121
+ })
122
+ })
@@ -0,0 +1,111 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { DataTable } from '../DataTable'
4
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
7
+ import { render } from '@testing-library/react'
8
+
9
+ jest.mock('next/navigation', () => ({
10
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
11
+ }))
12
+
13
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
14
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
15
+ }))
16
+
17
+ type Row = { id: string; title: string }
18
+
19
+ const tokensOf = (el: Element | null): string[] =>
20
+ (el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
21
+
22
+ // The header used to be a single non-wrapping `sm:flex-row` line. A title
23
+ // sitting next to a long action group (Create + Export + Refresh + injected
24
+ // toolbar buttons) had nothing but `min-w-0` holding it, so it collapsed to a
25
+ // sliver while the action buttons — which wrap on their own — spilled onto a
26
+ // second line and rendered over it. The row now wraps as a unit, the title
27
+ // keeps a 12rem floor, and the actions stay right-aligned in either layout.
28
+ describe('DataTable header row wrapping', () => {
29
+ function renderHeaderTable() {
30
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'title', header: 'Title' }]
31
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
32
+ const result = render(
33
+ <QueryClientProvider client={queryClient}>
34
+ <I18nProvider locale="en" dict={{}}>
35
+ <DataTable
36
+ title="Documents"
37
+ actions={<button type="button">Create document</button>}
38
+ columns={columns}
39
+ data={[{ id: '1', title: 'Solar rollout' }]}
40
+ />
41
+ </I18nProvider>
42
+ </QueryClientProvider>,
43
+ )
44
+ return { ...result, queryClient }
45
+ }
46
+
47
+ function headerParts(container: HTMLElement) {
48
+ const headerContent = Array.from(container.querySelectorAll('div')).find((el) =>
49
+ tokensOf(el).includes('sm:flex-row'),
50
+ ) ?? null
51
+ return {
52
+ headerContent,
53
+ titleSlot: headerContent?.firstElementChild ?? null,
54
+ actionsSlot: headerContent?.lastElementChild ?? null,
55
+ }
56
+ }
57
+
58
+ it('lets the header row wrap instead of crushing the title', () => {
59
+ const { container, queryClient } = renderHeaderTable()
60
+ try {
61
+ const { headerContent } = headerParts(container)
62
+ expect(headerContent).not.toBeNull()
63
+ expect(tokensOf(headerContent)).toEqual(
64
+ expect.arrayContaining([
65
+ 'flex',
66
+ 'flex-col',
67
+ 'gap-2',
68
+ 'sm:flex-row',
69
+ 'sm:flex-wrap',
70
+ 'sm:items-center',
71
+ 'sm:justify-between',
72
+ ]),
73
+ )
74
+ } finally {
75
+ queryClient.clear()
76
+ }
77
+ })
78
+
79
+ it('gives the title a wrap threshold instead of an unbounded shrink', () => {
80
+ const { container, queryClient } = renderHeaderTable()
81
+ try {
82
+ const { titleSlot } = headerParts(container)
83
+ expect(titleSlot?.textContent).toContain('Documents')
84
+ expect(tokensOf(titleSlot)).toEqual(
85
+ expect.arrayContaining(['flex-1', 'min-w-0', 'sm:basis-48']),
86
+ )
87
+ } finally {
88
+ queryClient.clear()
89
+ }
90
+ })
91
+
92
+ it('keeps the actions right-aligned once the row has wrapped', () => {
93
+ const { container, queryClient } = renderHeaderTable()
94
+ try {
95
+ const { actionsSlot } = headerParts(container)
96
+ expect(actionsSlot?.textContent).toContain('Create document')
97
+ expect(tokensOf(actionsSlot)).toEqual(
98
+ expect.arrayContaining([
99
+ 'flex',
100
+ 'flex-wrap',
101
+ 'items-center',
102
+ 'gap-2',
103
+ 'sm:ml-auto',
104
+ 'sm:justify-end',
105
+ ]),
106
+ )
107
+ } finally {
108
+ queryClient.clear()
109
+ }
110
+ })
111
+ })
@@ -128,4 +128,41 @@ describe('DataTable SSR render', () => {
128
128
  queryClient.clear()
129
129
  }
130
130
  })
131
+
132
+ it('forwards the click event to onRowClick, and skips it when clicking the actions cell', () => {
133
+ const columns: ColumnDef<Row>[] = [
134
+ { accessorKey: 'name', header: 'Name' },
135
+ ]
136
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
137
+ let capturedRowTagName: string | null = null
138
+ const onRowClick = jest.fn((_row: Row, event: React.MouseEvent<HTMLTableRowElement>) => {
139
+ capturedRowTagName = event.currentTarget.tagName
140
+ })
141
+ try {
142
+ render(
143
+ <QueryClientProvider client={queryClient}>
144
+ <I18nProvider locale="en" dict={{}}>
145
+ <DataTable
146
+ columns={columns}
147
+ data={[{ id: '1', name: 'Ada' }]}
148
+ onRowClick={onRowClick}
149
+ rowActions={() => <button type="button">Edit</button>}
150
+ />
151
+ </I18nProvider>
152
+ </QueryClientProvider>,
153
+ )
154
+
155
+ fireEvent.click(screen.getByText('Ada'))
156
+ expect(onRowClick).toHaveBeenCalledTimes(1)
157
+ const [row, event] = onRowClick.mock.calls[0]
158
+ expect(row).toEqual({ id: '1', name: 'Ada' })
159
+ expect(event.type).toBe('click')
160
+ expect(capturedRowTagName).toBe('TR')
161
+
162
+ fireEvent.click(screen.getByText('Edit'))
163
+ expect(onRowClick).toHaveBeenCalledTimes(1)
164
+ } finally {
165
+ queryClient.clear()
166
+ }
167
+ })
131
168
  })
@@ -7,6 +7,33 @@ import { NotesSection, type NotesDataAdapter } from '../detail/NotesSection'
7
7
  import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
8
8
  import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
9
9
 
10
+ const createDataAdapter = (): NotesDataAdapter => ({
11
+ list: jest.fn(async () => [
12
+ {
13
+ id: 'note-1',
14
+ body: 'Existing note',
15
+ createdAt: '2026-04-10T08:00:00.000Z',
16
+ authorName: 'Ada Lovelace',
17
+ },
18
+ ]),
19
+ create: jest.fn(async () => ({ id: 'note-2' })),
20
+ update: jest.fn(async () => undefined),
21
+ delete: jest.fn(async () => undefined),
22
+ })
23
+
24
+ const baseProps = (dataAdapter: NotesDataAdapter) => ({
25
+ entityId: 'person-1',
26
+ emptyLabel: '—',
27
+ viewerUserId: 'user-1',
28
+ viewerName: 'Ada Lovelace',
29
+ addActionLabel: 'Add note',
30
+ emptyState: {
31
+ title: 'No notes yet',
32
+ actionLabel: 'Add note',
33
+ },
34
+ dataAdapter,
35
+ })
36
+
10
37
  describe('NotesSection', () => {
11
38
  beforeEach(() => {
12
39
  dismissRecordConflict()
@@ -171,6 +198,68 @@ describe('NotesSection', () => {
171
198
  })
172
199
  })
173
200
 
201
+ it('renders the markdown toggle and appearance controls by default', async () => {
202
+ const { container } = renderWithProviders(<NotesSection {...baseProps(createDataAdapter())} />)
203
+
204
+ await screen.findByText('Existing note')
205
+ expect(container.querySelectorAll('svg.lucide-palette').length).toBe(1)
206
+
207
+ fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
208
+
209
+ await waitFor(() => {
210
+ expect(container.querySelector('button[aria-pressed]')).not.toBeNull()
211
+ })
212
+ expect(screen.getByRole('button', { name: 'Customize appearance' })).toBeTruthy()
213
+ expect(screen.queryByTestId('markdown-field')).toBeNull()
214
+ })
215
+
216
+ it('keeps the rich editor active and hides the toggle when forceMarkdown is set', async () => {
217
+ const { container } = renderWithProviders(
218
+ <NotesSection {...baseProps(createDataAdapter())} forceMarkdown />,
219
+ )
220
+
221
+ await screen.findByText('Existing note')
222
+ fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
223
+
224
+ await waitFor(() => {
225
+ expect(screen.getByTestId('markdown-field')).toBeTruthy()
226
+ })
227
+ expect(container.querySelector('button[aria-pressed]')).toBeNull()
228
+ })
229
+
230
+ it('hides the toggle but honors the seeded preference when hideMarkdownToggle is set', async () => {
231
+ const { container } = renderWithProviders(
232
+ <NotesSection
233
+ {...baseProps(createDataAdapter())}
234
+ hideMarkdownToggle
235
+ readMarkdownPreference={() => true}
236
+ />,
237
+ )
238
+
239
+ await screen.findByText('Existing note')
240
+ fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
241
+
242
+ await waitFor(() => {
243
+ expect(screen.getByTestId('markdown-field')).toBeTruthy()
244
+ })
245
+ expect(container.querySelector('button[aria-pressed]')).toBeNull()
246
+ })
247
+
248
+ it('removes every appearance affordance when disableAppearance is set', async () => {
249
+ const { container } = renderWithProviders(
250
+ <NotesSection {...baseProps(createDataAdapter())} disableAppearance />,
251
+ )
252
+
253
+ await screen.findByText('Existing note')
254
+ fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
255
+
256
+ await waitFor(() => {
257
+ expect(container.querySelector('button[aria-pressed]')).not.toBeNull()
258
+ })
259
+ expect(screen.queryByRole('button', { name: 'Customize appearance' })).toBeNull()
260
+ expect(container.querySelectorAll('svg.lucide-palette').length).toBe(0)
261
+ })
262
+
174
263
  it('surfaces the unified conflict bar when a write fails with a 409', async () => {
175
264
  const conflict = {
176
265
  status: 409,
@@ -0,0 +1,305 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { z } from 'zod'
5
+ import {
6
+ registerComponent,
7
+ registerComponentOverrides,
8
+ } from '@open-mercato/shared/modules/widgets/component-registry'
9
+ import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
10
+ import { ComponentOverrideProvider, useOverrideUserFeatures } from '../injection/ComponentOverrideProvider'
11
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
12
+
13
+ jest.mock('../utils/apiCall', () => ({
14
+ apiCall: jest.fn(async () => ({ ok: true, result: { granted: ['acme.override'] } })),
15
+ }))
16
+
17
+ type FieldProps = { label?: string }
18
+
19
+ const mounts = { count: 0 }
20
+
21
+ function StatefulField({ label }: FieldProps) {
22
+ React.useEffect(() => {
23
+ mounts.count += 1
24
+ }, [])
25
+ return (
26
+ <label>
27
+ {label ?? 'field'}
28
+ <input data-testid="field" defaultValue="" />
29
+ </label>
30
+ )
31
+ }
32
+
33
+ function FeaturesProbe() {
34
+ const features = useOverrideUserFeatures()
35
+ return <span data-testid="features">{features.join(',')}</span>
36
+ }
37
+
38
+ function typeIntoField(value: string) {
39
+ fireEvent.change(screen.getByTestId('field'), { target: { value } })
40
+ }
41
+
42
+ describe('useRegisteredComponent identity', () => {
43
+ beforeEach(() => {
44
+ mounts.count = 0
45
+ registerComponentOverrides([])
46
+ })
47
+
48
+ afterEach(() => {
49
+ registerComponentOverrides([])
50
+ })
51
+
52
+ it('keeps the subtree mounted when override user features resolve after first paint', async () => {
53
+ const componentId = 'test.identity.features'
54
+ registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
55
+
56
+ function Consumer() {
57
+ const Resolved = useRegisteredComponent<FieldProps>(componentId)
58
+ return <Resolved />
59
+ }
60
+
61
+ // The override targets a different component, so the resolution result for
62
+ // `componentId` is identical before and after the feature grant arrives.
63
+ const overrides: ComponentOverride[] = [
64
+ {
65
+ target: { componentId: 'test.identity.other' },
66
+ priority: 10,
67
+ features: ['acme.override'],
68
+ metadata: { module: 'test' },
69
+ propsTransform: (props: unknown) => props,
70
+ } as ComponentOverride,
71
+ ]
72
+
73
+ render(
74
+ <ComponentOverrideProvider overrides={overrides}>
75
+ <FeaturesProbe />
76
+ <Consumer />
77
+ </ComponentOverrideProvider>,
78
+ )
79
+
80
+ typeIntoField('admin@acme.com')
81
+ expect(mounts.count).toBe(1)
82
+
83
+ await waitFor(() => expect(screen.getByTestId('features')).toHaveTextContent('acme.override'))
84
+
85
+ expect(mounts.count).toBe(1)
86
+ expect(screen.getByTestId('field')).toHaveValue('admin@acme.com')
87
+ })
88
+
89
+ it('keeps the subtree mounted when overrides are registered late', async () => {
90
+ const componentId = 'test.identity.late'
91
+ registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
92
+
93
+ function Consumer() {
94
+ const Resolved = useRegisteredComponent<FieldProps>(componentId)
95
+ return <Resolved />
96
+ }
97
+
98
+ const lateOverrides: ComponentOverride[] = [
99
+ {
100
+ target: { componentId: 'test.identity.late.other' },
101
+ priority: 10,
102
+ metadata: { module: 'test' },
103
+ propsTransform: (props: unknown) => props,
104
+ } as ComponentOverride,
105
+ ]
106
+
107
+ const { rerender } = render(
108
+ <ComponentOverrideProvider overrides={[]}>
109
+ <Consumer />
110
+ </ComponentOverrideProvider>,
111
+ )
112
+
113
+ typeIntoField('typed before the registry settled')
114
+ expect(mounts.count).toBe(1)
115
+
116
+ await act(async () => {
117
+ rerender(
118
+ <ComponentOverrideProvider overrides={lateOverrides}>
119
+ <Consumer />
120
+ </ComponentOverrideProvider>,
121
+ )
122
+ })
123
+
124
+ expect(mounts.count).toBe(1)
125
+ expect(screen.getByTestId('field')).toHaveValue('typed before the registry settled')
126
+ })
127
+
128
+ it('keeps a wrapper-wrapped subtree mounted across override re-registrations', async () => {
129
+ const componentId = 'test.identity.wrapped'
130
+ registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
131
+
132
+ const wrapper = (Original: React.ComponentType<FieldProps>) => {
133
+ const Wrapped = (props: FieldProps) => (
134
+ <div data-testid="wrapped">
135
+ <Original {...props} />
136
+ </div>
137
+ )
138
+ Wrapped.displayName = 'Wrapped'
139
+ return Wrapped
140
+ }
141
+
142
+ const buildOverrides = (): ComponentOverride[] => [
143
+ { target: { componentId }, priority: 10, metadata: { module: 'test' }, wrapper } as unknown as ComponentOverride,
144
+ ]
145
+
146
+ function Consumer() {
147
+ const Resolved = useRegisteredComponent<FieldProps>(componentId)
148
+ return <Resolved />
149
+ }
150
+
151
+ const { rerender } = render(
152
+ <ComponentOverrideProvider overrides={buildOverrides()}>
153
+ <Consumer />
154
+ </ComponentOverrideProvider>,
155
+ )
156
+
157
+ expect(screen.getByTestId('wrapped')).toBeInTheDocument()
158
+ typeIntoField('typed inside a wrapped section')
159
+ expect(mounts.count).toBe(1)
160
+
161
+ // A new array with the same wrapper function — what a provider re-render looks like.
162
+ await act(async () => {
163
+ rerender(
164
+ <ComponentOverrideProvider overrides={buildOverrides()}>
165
+ <Consumer />
166
+ </ComponentOverrideProvider>,
167
+ )
168
+ })
169
+
170
+ expect(mounts.count).toBe(1)
171
+ expect(screen.getByTestId('field')).toHaveValue('typed inside a wrapped section')
172
+ })
173
+
174
+ it('composes a wrapper at most once per (wrapper, wrapped component) pair', async () => {
175
+ // The user-visible consequence is covered by the test above; this one pins the
176
+ // cache's own contract, so that dropping the memoization fails loudly and the
177
+ // purity requirement documented on the `wrapper` override member stays executable.
178
+ const componentId = 'test.identity.wrapper-composition'
179
+ const Base = (props: FieldProps) => <StatefulField {...props} />
180
+ Base.displayName = 'Base'
181
+ registerComponent({ id: componentId, component: Base, metadata: { module: 'test' } })
182
+
183
+ let compositions = 0
184
+ const wrapper = (Original: React.ComponentType<FieldProps>) => {
185
+ compositions += 1
186
+ const Wrapped = (props: FieldProps) => (
187
+ <div data-testid="wrapped">
188
+ <Original {...props} />
189
+ </div>
190
+ )
191
+ Wrapped.displayName = 'Wrapped'
192
+ return Wrapped
193
+ }
194
+
195
+ const buildOverrides = (): ComponentOverride[] => [
196
+ { target: { componentId }, priority: 10, metadata: { module: 'test' }, wrapper } as unknown as ComponentOverride,
197
+ ]
198
+
199
+ function Consumer() {
200
+ const Resolved = useRegisteredComponent<FieldProps>(componentId)
201
+ return <Resolved />
202
+ }
203
+
204
+ const { rerender } = render(
205
+ <ComponentOverrideProvider overrides={buildOverrides()}>
206
+ <Consumer />
207
+ </ComponentOverrideProvider>,
208
+ )
209
+
210
+ expect(screen.getByTestId('wrapped')).toBeInTheDocument()
211
+ expect(compositions).toBe(1)
212
+
213
+ for (let pass = 0; pass < 2; pass += 1) {
214
+ await act(async () => {
215
+ rerender(
216
+ <ComponentOverrideProvider overrides={buildOverrides()}>
217
+ <Consumer />
218
+ </ComponentOverrideProvider>,
219
+ )
220
+ })
221
+ }
222
+
223
+ expect(compositions).toBe(1)
224
+ expect(mounts.count).toBe(1)
225
+ })
226
+
227
+ it('still applies a replacement that becomes active after the first render', async () => {
228
+ const componentId = 'test.identity.replacement'
229
+ const Original = () => <span data-testid="rendered">original</span>
230
+ const Replacement = () => <span data-testid="rendered">replacement</span>
231
+ registerComponent({ id: componentId, component: Original, metadata: { module: 'test' } })
232
+
233
+ function Consumer() {
234
+ const Resolved = useRegisteredComponent<FieldProps>(componentId)
235
+ return <Resolved />
236
+ }
237
+
238
+ const lateOverrides: ComponentOverride[] = [
239
+ {
240
+ target: { componentId },
241
+ priority: 100,
242
+ metadata: { module: 'test' },
243
+ replacement: Replacement,
244
+ propsSchema: z.object({}).passthrough(),
245
+ } as unknown as ComponentOverride,
246
+ ]
247
+
248
+ const { rerender } = render(
249
+ <ComponentOverrideProvider overrides={[]}>
250
+ <Consumer />
251
+ </ComponentOverrideProvider>,
252
+ )
253
+ expect(screen.getByTestId('rendered')).toHaveTextContent('original')
254
+
255
+ await act(async () => {
256
+ rerender(
257
+ <ComponentOverrideProvider overrides={lateOverrides}>
258
+ <Consumer />
259
+ </ComponentOverrideProvider>,
260
+ )
261
+ })
262
+
263
+ expect(screen.getByTestId('rendered')).toHaveTextContent('replacement')
264
+ })
265
+
266
+ it('cannot save a subtree whose host re-creates the fallback on every render', () => {
267
+ const componentId = 'test.identity.inline-fallback'
268
+
269
+ function Consumer({ label }: { label: string }) {
270
+ // A fallback declared inside the host's render body is a new function on
271
+ // every render, and with no component registered under this id it *is*
272
+ // the component being rendered. The hook keeps its own identity stable,
273
+ // but it cannot make the caller's fallback stable — React sees a new
274
+ // element type below and must remount. Hosts that care about the state
275
+ // under an unregistered id have to hoist the fallback out of render.
276
+ const Fallback = (props: FieldProps) => <StatefulField {...props} />
277
+ const Resolved = useRegisteredComponent<FieldProps>(componentId, Fallback)
278
+ return <Resolved label={label} />
279
+ }
280
+
281
+ const { rerender } = render(<Consumer label="first" />)
282
+ typeIntoField('typed against an inline fallback')
283
+ expect(mounts.count).toBe(1)
284
+
285
+ rerender(<Consumer label="second" />)
286
+
287
+ expect(mounts.count).toBe(2)
288
+ })
289
+
290
+ it('remounts when the host asks for a different component id', () => {
291
+ function Consumer({ componentId }: { componentId: string }) {
292
+ const Resolved = useRegisteredComponent<FieldProps>(componentId, StatefulField)
293
+ return <Resolved />
294
+ }
295
+
296
+ const { rerender } = render(<Consumer componentId="test.identity.provider-a" />)
297
+ typeIntoField('provider a input')
298
+ expect(mounts.count).toBe(1)
299
+
300
+ rerender(<Consumer componentId="test.identity.provider-b" />)
301
+
302
+ expect(mounts.count).toBe(2)
303
+ expect(screen.getByTestId('field')).toHaveValue('')
304
+ })
305
+ })