@easyops-cn/a2ui-react 0.0.0

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 (161) hide show
  1. package/.claude/commands/speckit.analyze.md +184 -0
  2. package/.claude/commands/speckit.checklist.md +294 -0
  3. package/.claude/commands/speckit.clarify.md +181 -0
  4. package/.claude/commands/speckit.constitution.md +82 -0
  5. package/.claude/commands/speckit.implement.md +135 -0
  6. package/.claude/commands/speckit.plan.md +89 -0
  7. package/.claude/commands/speckit.specify.md +256 -0
  8. package/.claude/commands/speckit.tasks.md +137 -0
  9. package/.claude/commands/speckit.taskstoissues.md +30 -0
  10. package/.github/workflows/deploy.yml +69 -0
  11. package/.husky/pre-commit +1 -0
  12. package/.prettierignore +4 -0
  13. package/.prettierrc +7 -0
  14. package/.specify/memory/constitution.md +73 -0
  15. package/.specify/scripts/bash/check-prerequisites.sh +166 -0
  16. package/.specify/scripts/bash/common.sh +156 -0
  17. package/.specify/scripts/bash/create-new-feature.sh +297 -0
  18. package/.specify/scripts/bash/setup-plan.sh +61 -0
  19. package/.specify/scripts/bash/update-agent-context.sh +799 -0
  20. package/.specify/templates/agent-file-template.md +28 -0
  21. package/.specify/templates/checklist-template.md +40 -0
  22. package/.specify/templates/plan-template.md +105 -0
  23. package/.specify/templates/spec-template.md +115 -0
  24. package/.specify/templates/tasks-template.md +250 -0
  25. package/CLAUDE.md +105 -0
  26. package/CONTRIBUTING.md +97 -0
  27. package/README.md +126 -0
  28. package/components.json +21 -0
  29. package/eslint.config.js +25 -0
  30. package/netlify.toml +50 -0
  31. package/package.json +94 -0
  32. package/playground/README.md +75 -0
  33. package/playground/index.html +22 -0
  34. package/playground/package.json +32 -0
  35. package/playground/public/favicon.svg +8 -0
  36. package/playground/src/App.css +256 -0
  37. package/playground/src/App.tsx +115 -0
  38. package/playground/src/assets/react.svg +1 -0
  39. package/playground/src/components/ErrorDisplay.tsx +13 -0
  40. package/playground/src/components/ExampleSelector.tsx +64 -0
  41. package/playground/src/components/Header.tsx +47 -0
  42. package/playground/src/components/JsonEditor.tsx +32 -0
  43. package/playground/src/components/Preview.tsx +78 -0
  44. package/playground/src/components/ThemeToggle.tsx +19 -0
  45. package/playground/src/data/examples.ts +1571 -0
  46. package/playground/src/hooks/useTheme.ts +55 -0
  47. package/playground/src/index.css +220 -0
  48. package/playground/src/main.tsx +10 -0
  49. package/playground/tsconfig.app.json +34 -0
  50. package/playground/tsconfig.json +13 -0
  51. package/playground/tsconfig.node.json +26 -0
  52. package/playground/vite.config.ts +31 -0
  53. package/specs/001-a2ui-renderer/checklists/requirements.md +41 -0
  54. package/specs/001-a2ui-renderer/data-model.md +140 -0
  55. package/specs/001-a2ui-renderer/plan.md +123 -0
  56. package/specs/001-a2ui-renderer/quickstart.md +141 -0
  57. package/specs/001-a2ui-renderer/research.md +140 -0
  58. package/specs/001-a2ui-renderer/spec.md +165 -0
  59. package/specs/001-a2ui-renderer/tasks.md +310 -0
  60. package/specs/002-playground/checklists/requirements.md +37 -0
  61. package/specs/002-playground/contracts/components.md +120 -0
  62. package/specs/002-playground/data-model.md +149 -0
  63. package/specs/002-playground/plan.md +73 -0
  64. package/specs/002-playground/quickstart.md +158 -0
  65. package/specs/002-playground/research.md +117 -0
  66. package/specs/002-playground/spec.md +109 -0
  67. package/specs/002-playground/tasks.md +224 -0
  68. package/src/0.8/A2UIRender.test.tsx +793 -0
  69. package/src/0.8/A2UIRender.tsx +142 -0
  70. package/src/0.8/components/ComponentRenderer.test.tsx +373 -0
  71. package/src/0.8/components/ComponentRenderer.tsx +163 -0
  72. package/src/0.8/components/UnknownComponent.tsx +49 -0
  73. package/src/0.8/components/display/AudioPlayerComponent.tsx +37 -0
  74. package/src/0.8/components/display/DividerComponent.tsx +23 -0
  75. package/src/0.8/components/display/IconComponent.tsx +137 -0
  76. package/src/0.8/components/display/ImageComponent.tsx +57 -0
  77. package/src/0.8/components/display/TextComponent.tsx +56 -0
  78. package/src/0.8/components/display/VideoComponent.tsx +31 -0
  79. package/src/0.8/components/display/display.test.tsx +660 -0
  80. package/src/0.8/components/display/index.ts +10 -0
  81. package/src/0.8/components/index.ts +14 -0
  82. package/src/0.8/components/interactive/ButtonComponent.tsx +44 -0
  83. package/src/0.8/components/interactive/CheckBoxComponent.tsx +45 -0
  84. package/src/0.8/components/interactive/DateTimeInputComponent.tsx +176 -0
  85. package/src/0.8/components/interactive/MultipleChoiceComponent.tsx +157 -0
  86. package/src/0.8/components/interactive/SliderComponent.tsx +53 -0
  87. package/src/0.8/components/interactive/TextFieldComponent.tsx +65 -0
  88. package/src/0.8/components/interactive/index.ts +10 -0
  89. package/src/0.8/components/interactive/interactive.test.tsx +618 -0
  90. package/src/0.8/components/layout/CardComponent.tsx +30 -0
  91. package/src/0.8/components/layout/ColumnComponent.tsx +93 -0
  92. package/src/0.8/components/layout/ListComponent.tsx +81 -0
  93. package/src/0.8/components/layout/ModalComponent.tsx +41 -0
  94. package/src/0.8/components/layout/RowComponent.tsx +94 -0
  95. package/src/0.8/components/layout/TabsComponent.tsx +59 -0
  96. package/src/0.8/components/layout/index.ts +10 -0
  97. package/src/0.8/components/layout/layout.test.tsx +558 -0
  98. package/src/0.8/contexts/A2UIProvider.test.tsx +226 -0
  99. package/src/0.8/contexts/A2UIProvider.tsx +54 -0
  100. package/src/0.8/contexts/ActionContext.test.tsx +242 -0
  101. package/src/0.8/contexts/ActionContext.tsx +105 -0
  102. package/src/0.8/contexts/ComponentsMapContext.tsx +125 -0
  103. package/src/0.8/contexts/DataModelContext.test.tsx +335 -0
  104. package/src/0.8/contexts/DataModelContext.tsx +184 -0
  105. package/src/0.8/contexts/SurfaceContext.test.tsx +339 -0
  106. package/src/0.8/contexts/SurfaceContext.tsx +197 -0
  107. package/src/0.8/hooks/useA2UIMessageHandler.test.tsx +399 -0
  108. package/src/0.8/hooks/useA2UIMessageHandler.ts +123 -0
  109. package/src/0.8/hooks/useComponent.test.tsx +148 -0
  110. package/src/0.8/hooks/useComponent.ts +39 -0
  111. package/src/0.8/hooks/useDataBinding.test.tsx +334 -0
  112. package/src/0.8/hooks/useDataBinding.ts +99 -0
  113. package/src/0.8/hooks/useDispatchAction.test.tsx +83 -0
  114. package/src/0.8/hooks/useDispatchAction.ts +35 -0
  115. package/src/0.8/hooks/useSurface.test.tsx +114 -0
  116. package/src/0.8/hooks/useSurface.ts +34 -0
  117. package/src/0.8/index.ts +38 -0
  118. package/src/0.8/schemas/client_to_server.json +50 -0
  119. package/src/0.8/schemas/server_to_client.json +148 -0
  120. package/src/0.8/schemas/standard_catalog_definition.json +661 -0
  121. package/src/0.8/types/index.ts +448 -0
  122. package/src/0.8/utils/dataBinding.test.ts +443 -0
  123. package/src/0.8/utils/dataBinding.ts +212 -0
  124. package/src/0.8/utils/pathUtils.test.ts +353 -0
  125. package/src/0.8/utils/pathUtils.ts +200 -0
  126. package/src/components/ui/button.tsx +62 -0
  127. package/src/components/ui/calendar.tsx +220 -0
  128. package/src/components/ui/card.tsx +92 -0
  129. package/src/components/ui/checkbox.tsx +30 -0
  130. package/src/components/ui/dialog.tsx +141 -0
  131. package/src/components/ui/input.tsx +21 -0
  132. package/src/components/ui/label.tsx +22 -0
  133. package/src/components/ui/native-select.tsx +53 -0
  134. package/src/components/ui/popover.tsx +46 -0
  135. package/src/components/ui/select.tsx +188 -0
  136. package/src/components/ui/separator.tsx +26 -0
  137. package/src/components/ui/slider.tsx +61 -0
  138. package/src/components/ui/tabs.tsx +64 -0
  139. package/src/components/ui/textarea.tsx +18 -0
  140. package/src/index.ts +1 -0
  141. package/src/lib/utils.ts +6 -0
  142. package/tsconfig.json +28 -0
  143. package/vite.config.ts +29 -0
  144. package/vitest.config.ts +22 -0
  145. package/vitest.setup.ts +8 -0
  146. package/website/README.md +4 -0
  147. package/website/assets/favicon.svg +8 -0
  148. package/website/content/.gitkeep +0 -0
  149. package/website/content/index.md +122 -0
  150. package/website/global.d.ts +9 -0
  151. package/website/package.json +17 -0
  152. package/website/plain.config.js +28 -0
  153. package/website/serve.json +6 -0
  154. package/website/src/client/color-mode-switch.css +47 -0
  155. package/website/src/client/index.js +61 -0
  156. package/website/src/client/moon.svg +1 -0
  157. package/website/src/client/sun.svg +1 -0
  158. package/website/src/components/Footer.jsx +9 -0
  159. package/website/src/components/Header.jsx +44 -0
  160. package/website/src/components/Page.jsx +28 -0
  161. package/website/src/global.css +423 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * useA2UIMessageHandler Tests
3
+ *
4
+ * Tests for the useA2UIMessageHandler hook.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest'
8
+ import { renderHook, act } from '@testing-library/react'
9
+ import { useA2UIMessageHandler } from './useA2UIMessageHandler'
10
+ import { A2UIProvider } from '../contexts/A2UIProvider'
11
+ import { useSurfaceContext } from '../contexts/SurfaceContext'
12
+ import { useDataModelContext } from '../contexts/DataModelContext'
13
+ import type { ReactNode } from 'react'
14
+ import type { A2UIMessage } from '../types'
15
+
16
+ describe('useA2UIMessageHandler', () => {
17
+ // Helper to render hook with provider
18
+ const wrapper = ({ children }: { children: ReactNode }) => (
19
+ <A2UIProvider>{children}</A2UIProvider>
20
+ )
21
+
22
+ describe('processMessage', () => {
23
+ it('should handle beginRendering message', () => {
24
+ const { result } = renderHook(
25
+ () => {
26
+ const handler = useA2UIMessageHandler()
27
+ const { getSurface } = useSurfaceContext()
28
+ const { getDataModel } = useDataModelContext()
29
+ return { handler, getSurface, getDataModel }
30
+ },
31
+ { wrapper }
32
+ )
33
+
34
+ const message: A2UIMessage = {
35
+ beginRendering: {
36
+ surfaceId: 'surface-1',
37
+ root: 'root-component',
38
+ styles: { font: 'Arial' },
39
+ },
40
+ }
41
+
42
+ act(() => {
43
+ result.current.handler.processMessage(message)
44
+ })
45
+
46
+ const surface = result.current.getSurface('surface-1')
47
+ expect(surface).toBeDefined()
48
+ expect(surface?.surfaceId).toBe('surface-1')
49
+ expect(surface?.root).toBe('root-component')
50
+ expect(surface?.styles?.font).toBe('Arial')
51
+
52
+ // Data model should be initialized
53
+ expect(result.current.getDataModel('surface-1')).toEqual({})
54
+ })
55
+
56
+ it('should handle surfaceUpdate message', () => {
57
+ const { result } = renderHook(
58
+ () => {
59
+ const handler = useA2UIMessageHandler()
60
+ const { getSurface, getComponent } = useSurfaceContext()
61
+ return { handler, getSurface, getComponent }
62
+ },
63
+ { wrapper }
64
+ )
65
+
66
+ // First init surface
67
+ act(() => {
68
+ result.current.handler.processMessage({
69
+ beginRendering: {
70
+ surfaceId: 'surface-1',
71
+ root: 'root',
72
+ },
73
+ })
74
+ })
75
+
76
+ // Then update with components
77
+ const message: A2UIMessage = {
78
+ surfaceUpdate: {
79
+ surfaceId: 'surface-1',
80
+ components: [
81
+ {
82
+ id: 'text-1',
83
+ component: { Text: { text: { literalString: 'Hello' } } },
84
+ },
85
+ {
86
+ id: 'button-1',
87
+ component: { Button: { action: { name: 'click' } } },
88
+ },
89
+ ],
90
+ },
91
+ }
92
+
93
+ act(() => {
94
+ result.current.handler.processMessage(message)
95
+ })
96
+
97
+ expect(result.current.getComponent('surface-1', 'text-1')).toBeDefined()
98
+ expect(result.current.getComponent('surface-1', 'button-1')).toBeDefined()
99
+ })
100
+
101
+ it('should handle surfaceUpdate before beginRendering', () => {
102
+ const { result } = renderHook(
103
+ () => {
104
+ const handler = useA2UIMessageHandler()
105
+ const { getSurface, getComponent } = useSurfaceContext()
106
+ return { handler, getSurface, getComponent }
107
+ },
108
+ { wrapper }
109
+ )
110
+
111
+ // surfaceUpdate first
112
+ act(() => {
113
+ result.current.handler.processMessage({
114
+ surfaceUpdate: {
115
+ surfaceId: 'surface-1',
116
+ components: [{ id: 'text-1', component: { Text: {} } }],
117
+ },
118
+ })
119
+ })
120
+
121
+ // Component should exist
122
+ expect(result.current.getComponent('surface-1', 'text-1')).toBeDefined()
123
+
124
+ // Then beginRendering
125
+ act(() => {
126
+ result.current.handler.processMessage({
127
+ beginRendering: {
128
+ surfaceId: 'surface-1',
129
+ root: 'text-1',
130
+ },
131
+ })
132
+ })
133
+
134
+ // Surface should have root set and component preserved
135
+ const surface = result.current.getSurface('surface-1')
136
+ expect(surface?.root).toBe('text-1')
137
+ expect(result.current.getComponent('surface-1', 'text-1')).toBeDefined()
138
+ })
139
+
140
+ it('should handle dataModelUpdate message', () => {
141
+ const { result } = renderHook(
142
+ () => {
143
+ const handler = useA2UIMessageHandler()
144
+ const { getDataValue } = useDataModelContext()
145
+ return { handler, getDataValue }
146
+ },
147
+ { wrapper }
148
+ )
149
+
150
+ // Init surface first
151
+ act(() => {
152
+ result.current.handler.processMessage({
153
+ beginRendering: {
154
+ surfaceId: 'surface-1',
155
+ root: 'root',
156
+ },
157
+ })
158
+ })
159
+
160
+ // Update data model
161
+ const message: A2UIMessage = {
162
+ dataModelUpdate: {
163
+ surfaceId: 'surface-1',
164
+ path: '/',
165
+ contents: [
166
+ { key: 'name', valueString: 'John' },
167
+ { key: 'age', valueNumber: 30 },
168
+ { key: 'active', valueBoolean: true },
169
+ ],
170
+ },
171
+ }
172
+
173
+ act(() => {
174
+ result.current.handler.processMessage(message)
175
+ })
176
+
177
+ expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
178
+ expect(result.current.getDataValue('surface-1', '/age')).toBe(30)
179
+ expect(result.current.getDataValue('surface-1', '/active')).toBe(true)
180
+ })
181
+
182
+ it('should handle dataModelUpdate with nested path', () => {
183
+ const { result } = renderHook(
184
+ () => {
185
+ const handler = useA2UIMessageHandler()
186
+ const { getDataValue } = useDataModelContext()
187
+ return { handler, getDataValue }
188
+ },
189
+ { wrapper }
190
+ )
191
+
192
+ act(() => {
193
+ result.current.handler.processMessage({
194
+ beginRendering: { surfaceId: 'surface-1', root: 'root' },
195
+ })
196
+ })
197
+
198
+ act(() => {
199
+ result.current.handler.processMessage({
200
+ dataModelUpdate: {
201
+ surfaceId: 'surface-1',
202
+ path: '/user',
203
+ contents: [
204
+ { key: 'name', valueString: 'John' },
205
+ { key: 'email', valueString: 'john@example.com' },
206
+ ],
207
+ },
208
+ })
209
+ })
210
+
211
+ expect(result.current.getDataValue('surface-1', '/user/name')).toBe(
212
+ 'John'
213
+ )
214
+ expect(result.current.getDataValue('surface-1', '/user/email')).toBe(
215
+ 'john@example.com'
216
+ )
217
+ })
218
+
219
+ it('should handle dataModelUpdate with default path', () => {
220
+ const { result } = renderHook(
221
+ () => {
222
+ const handler = useA2UIMessageHandler()
223
+ const { getDataValue } = useDataModelContext()
224
+ return { handler, getDataValue }
225
+ },
226
+ { wrapper }
227
+ )
228
+
229
+ act(() => {
230
+ result.current.handler.processMessage({
231
+ beginRendering: { surfaceId: 'surface-1', root: 'root' },
232
+ })
233
+ })
234
+
235
+ // No path specified - should default to '/'
236
+ act(() => {
237
+ result.current.handler.processMessage({
238
+ dataModelUpdate: {
239
+ surfaceId: 'surface-1',
240
+ contents: [{ key: 'name', valueString: 'John' }],
241
+ },
242
+ })
243
+ })
244
+
245
+ expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
246
+ })
247
+
248
+ it('should handle deleteSurface message', () => {
249
+ const { result } = renderHook(
250
+ () => {
251
+ const handler = useA2UIMessageHandler()
252
+ const { getSurface } = useSurfaceContext()
253
+ const { getDataModel } = useDataModelContext()
254
+ return { handler, getSurface, getDataModel }
255
+ },
256
+ { wrapper }
257
+ )
258
+
259
+ // Create surface
260
+ act(() => {
261
+ result.current.handler.processMessage({
262
+ beginRendering: { surfaceId: 'surface-1', root: 'root' },
263
+ })
264
+ result.current.handler.processMessage({
265
+ dataModelUpdate: {
266
+ surfaceId: 'surface-1',
267
+ contents: [{ key: 'name', valueString: 'John' }],
268
+ },
269
+ })
270
+ })
271
+
272
+ expect(result.current.getSurface('surface-1')).toBeDefined()
273
+
274
+ // Delete surface
275
+ act(() => {
276
+ result.current.handler.processMessage({
277
+ deleteSurface: { surfaceId: 'surface-1' },
278
+ })
279
+ })
280
+
281
+ expect(result.current.getSurface('surface-1')).toBeUndefined()
282
+ expect(result.current.getDataModel('surface-1')).toEqual({})
283
+ })
284
+
285
+ it('should handle empty message gracefully', () => {
286
+ const { result } = renderHook(() => useA2UIMessageHandler(), { wrapper })
287
+
288
+ // Should not throw
289
+ expect(() => {
290
+ act(() => {
291
+ result.current.processMessage({} as A2UIMessage)
292
+ })
293
+ }).not.toThrow()
294
+ })
295
+ })
296
+
297
+ describe('processMessages', () => {
298
+ it('should process multiple messages in order', () => {
299
+ const { result } = renderHook(
300
+ () => {
301
+ const handler = useA2UIMessageHandler()
302
+ const { getSurface, getComponent } = useSurfaceContext()
303
+ const { getDataValue } = useDataModelContext()
304
+ return { handler, getSurface, getComponent, getDataValue }
305
+ },
306
+ { wrapper }
307
+ )
308
+
309
+ const messages: A2UIMessage[] = [
310
+ {
311
+ beginRendering: { surfaceId: 'surface-1', root: 'root' },
312
+ },
313
+ {
314
+ surfaceUpdate: {
315
+ surfaceId: 'surface-1',
316
+ components: [{ id: 'text-1', component: { Text: {} } }],
317
+ },
318
+ },
319
+ {
320
+ dataModelUpdate: {
321
+ surfaceId: 'surface-1',
322
+ contents: [{ key: 'name', valueString: 'John' }],
323
+ },
324
+ },
325
+ ]
326
+
327
+ act(() => {
328
+ result.current.handler.processMessages(messages)
329
+ })
330
+
331
+ expect(result.current.getSurface('surface-1')).toBeDefined()
332
+ expect(result.current.getComponent('surface-1', 'text-1')).toBeDefined()
333
+ expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
334
+ })
335
+
336
+ it('should handle empty messages array', () => {
337
+ const { result } = renderHook(() => useA2UIMessageHandler(), { wrapper })
338
+
339
+ expect(() => {
340
+ act(() => {
341
+ result.current.processMessages([])
342
+ })
343
+ }).not.toThrow()
344
+ })
345
+ })
346
+
347
+ describe('clear', () => {
348
+ it('should clear all surfaces and data models', () => {
349
+ const { result } = renderHook(
350
+ () => {
351
+ const handler = useA2UIMessageHandler()
352
+ const { surfaces } = useSurfaceContext()
353
+ const { dataModels } = useDataModelContext()
354
+ return { handler, surfaces, dataModels }
355
+ },
356
+ { wrapper }
357
+ )
358
+
359
+ // Create multiple surfaces
360
+ act(() => {
361
+ result.current.handler.processMessages([
362
+ { beginRendering: { surfaceId: 'surface-1', root: 'root' } },
363
+ { beginRendering: { surfaceId: 'surface-2', root: 'root' } },
364
+ {
365
+ dataModelUpdate: {
366
+ surfaceId: 'surface-1',
367
+ contents: [{ key: 'name', valueString: 'John' }],
368
+ },
369
+ },
370
+ ])
371
+ })
372
+
373
+ expect(result.current.surfaces.size).toBe(2)
374
+ expect(result.current.dataModels.size).toBe(2)
375
+
376
+ // Clear all
377
+ act(() => {
378
+ result.current.handler.clear()
379
+ })
380
+
381
+ expect(result.current.surfaces.size).toBe(0)
382
+ expect(result.current.dataModels.size).toBe(0)
383
+ })
384
+ })
385
+
386
+ describe('error handling', () => {
387
+ it('should throw error when used outside provider', () => {
388
+ const consoleError = vi
389
+ .spyOn(console, 'error')
390
+ .mockImplementation(() => {})
391
+
392
+ expect(() => {
393
+ renderHook(() => useA2UIMessageHandler())
394
+ }).toThrow()
395
+
396
+ consoleError.mockRestore()
397
+ })
398
+ })
399
+ })
@@ -0,0 +1,123 @@
1
+ /**
2
+ * useA2UIMessageHandler - Hook for processing A2UI messages.
3
+ */
4
+
5
+ import { useCallback } from 'react'
6
+ import type { A2UIMessage } from '../types'
7
+ import { useSurfaceContext } from '../contexts/SurfaceContext'
8
+ import { useDataModelContext } from '../contexts/DataModelContext'
9
+ import { contentsToObject } from '../utils/dataBinding'
10
+
11
+ /**
12
+ * Return type for the message handler hook.
13
+ */
14
+ export interface A2UIMessageHandler {
15
+ /** Processes a single A2UI message */
16
+ processMessage: (message: A2UIMessage) => void
17
+
18
+ /** Processes multiple A2UI messages */
19
+ processMessages: (messages: A2UIMessage[]) => void
20
+
21
+ /** Clears all surfaces and data models */
22
+ clear: () => void
23
+ }
24
+
25
+ /**
26
+ * Hook that returns functions to process A2UI messages.
27
+ *
28
+ * @returns Object with processMessage, processMessages, and clear functions
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * function MyComponent() {
33
+ * const { processMessage, processMessages, clear } = useA2UIMessageHandler();
34
+ *
35
+ * useEffect(() => {
36
+ * // Process initial messages
37
+ * processMessages(initialMessages);
38
+ *
39
+ * // Listen for SSE updates
40
+ * const handler = (event) => {
41
+ * processMessage(event.detail.message);
42
+ * };
43
+ * window.addEventListener('a2ui-message', handler);
44
+ *
45
+ * return () => {
46
+ * window.removeEventListener('a2ui-message', handler);
47
+ * clear();
48
+ * };
49
+ * }, []);
50
+ * }
51
+ * ```
52
+ */
53
+ export function useA2UIMessageHandler(): A2UIMessageHandler {
54
+ const { initSurface, updateSurface, deleteSurface, clearSurfaces } =
55
+ useSurfaceContext()
56
+
57
+ const { updateDataModel, initDataModel, deleteDataModel, clearDataModels } =
58
+ useDataModelContext()
59
+
60
+ const processMessage = useCallback(
61
+ (messages: A2UIMessage) => {
62
+ // Handle beginRendering - marks the surface as ready to render
63
+ // Note: surfaceUpdate may have already populated components before this
64
+ if (messages.beginRendering) {
65
+ const { surfaceId, root, styles } = messages.beginRendering
66
+ initSurface(surfaceId, root, styles)
67
+ initDataModel(surfaceId)
68
+ return
69
+ }
70
+
71
+ // Handle surfaceUpdate
72
+ if (messages.surfaceUpdate) {
73
+ const { surfaceId, components } = messages.surfaceUpdate
74
+ updateSurface(surfaceId, components)
75
+ return
76
+ }
77
+
78
+ // Handle dataModelUpdate
79
+ if (messages.dataModelUpdate) {
80
+ const { surfaceId, path = '/', contents } = messages.dataModelUpdate
81
+ const data = contentsToObject(contents)
82
+ updateDataModel(surfaceId, path, data)
83
+ return
84
+ }
85
+
86
+ // Handle deleteSurface
87
+ if (messages.deleteSurface) {
88
+ const { surfaceId } = messages.deleteSurface
89
+ deleteSurface(surfaceId)
90
+ deleteDataModel(surfaceId)
91
+ return
92
+ }
93
+ },
94
+ [
95
+ initSurface,
96
+ updateSurface,
97
+ deleteSurface,
98
+ initDataModel,
99
+ updateDataModel,
100
+ deleteDataModel,
101
+ ]
102
+ )
103
+
104
+ const processMessages = useCallback(
105
+ (messages: A2UIMessage[]) => {
106
+ for (const message of messages) {
107
+ processMessage(message)
108
+ }
109
+ },
110
+ [processMessage]
111
+ )
112
+
113
+ const clear = useCallback(() => {
114
+ clearSurfaces()
115
+ clearDataModels()
116
+ }, [clearSurfaces, clearDataModels])
117
+
118
+ return {
119
+ processMessage,
120
+ processMessages,
121
+ clear,
122
+ }
123
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * useComponent Tests
3
+ *
4
+ * Tests for the useComponent hook.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest'
8
+ import { renderHook, act } from '@testing-library/react'
9
+ import { useComponent } from './useComponent'
10
+ import { SurfaceProvider, useSurfaceContext } from '../contexts/SurfaceContext'
11
+ import type { ReactNode } from 'react'
12
+ import type { ComponentDefinition } from '../types'
13
+
14
+ describe('useComponent', () => {
15
+ // Helper to render hook with provider
16
+ const wrapper = ({ children }: { children: ReactNode }) => (
17
+ <SurfaceProvider>{children}</SurfaceProvider>
18
+ )
19
+
20
+ it('should return undefined for non-existent surface', () => {
21
+ const { result } = renderHook(
22
+ () => useComponent('non-existent', 'comp-1'),
23
+ { wrapper }
24
+ )
25
+ expect(result.current).toBeUndefined()
26
+ })
27
+
28
+ it('should return undefined for non-existent component', () => {
29
+ const { result } = renderHook(
30
+ () => {
31
+ const component = useComponent('test-surface', 'non-existent')
32
+ const { initSurface } = useSurfaceContext()
33
+ return { component, initSurface }
34
+ },
35
+ { wrapper }
36
+ )
37
+
38
+ act(() => {
39
+ result.current.initSurface('test-surface', 'root')
40
+ })
41
+
42
+ expect(result.current.component).toBeUndefined()
43
+ })
44
+
45
+ it('should return component when it exists', () => {
46
+ const testComponent: ComponentDefinition = {
47
+ id: 'text-1',
48
+ component: { Text: { text: { literalString: 'Hello' } } },
49
+ }
50
+
51
+ const { result } = renderHook(
52
+ () => {
53
+ const component = useComponent('test-surface', 'text-1')
54
+ const { initSurface, updateSurface } = useSurfaceContext()
55
+ return { component, initSurface, updateSurface }
56
+ },
57
+ { wrapper }
58
+ )
59
+
60
+ act(() => {
61
+ result.current.initSurface('test-surface', 'root')
62
+ result.current.updateSurface('test-surface', [testComponent])
63
+ })
64
+
65
+ expect(result.current.component).toEqual(testComponent)
66
+ })
67
+
68
+ it('should update when component changes', () => {
69
+ const { result } = renderHook(
70
+ () => {
71
+ const component = useComponent('test-surface', 'text-1')
72
+ const { initSurface, updateSurface } = useSurfaceContext()
73
+ return { component, initSurface, updateSurface }
74
+ },
75
+ { wrapper }
76
+ )
77
+
78
+ act(() => {
79
+ result.current.initSurface('test-surface', 'root')
80
+ result.current.updateSurface('test-surface', [
81
+ {
82
+ id: 'text-1',
83
+ component: { Text: { text: { literalString: 'Hello' } } },
84
+ },
85
+ ])
86
+ })
87
+
88
+ expect(result.current.component?.component.Text?.text).toEqual({
89
+ literalString: 'Hello',
90
+ })
91
+
92
+ // Update component
93
+ act(() => {
94
+ result.current.updateSurface('test-surface', [
95
+ {
96
+ id: 'text-1',
97
+ component: { Text: { text: { literalString: 'Updated' } } },
98
+ },
99
+ ])
100
+ })
101
+
102
+ expect(result.current.component?.component.Text?.text).toEqual({
103
+ literalString: 'Updated',
104
+ })
105
+ })
106
+
107
+ it('should memoize result based on surfaceId and componentId', () => {
108
+ const { result, rerender } = renderHook(
109
+ ({ surfaceId, componentId }) => {
110
+ const component = useComponent(surfaceId, componentId)
111
+ const { initSurface, updateSurface } = useSurfaceContext()
112
+ return { component, initSurface, updateSurface }
113
+ },
114
+ {
115
+ wrapper,
116
+ initialProps: { surfaceId: 'surface-1', componentId: 'comp-1' },
117
+ }
118
+ )
119
+
120
+ act(() => {
121
+ result.current.initSurface('surface-1', 'root')
122
+ result.current.updateSurface('surface-1', [
123
+ { id: 'comp-1', component: { Text: { text: { literalString: 'A' } } } },
124
+ { id: 'comp-2', component: { Text: { text: { literalString: 'B' } } } },
125
+ ])
126
+ })
127
+
128
+ expect(result.current.component?.component.Text?.text).toEqual({
129
+ literalString: 'A',
130
+ })
131
+
132
+ // Change componentId
133
+ rerender({ surfaceId: 'surface-1', componentId: 'comp-2' })
134
+ expect(result.current.component?.component.Text?.text).toEqual({
135
+ literalString: 'B',
136
+ })
137
+ })
138
+
139
+ it('should throw error when used outside provider', () => {
140
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
141
+
142
+ expect(() => {
143
+ renderHook(() => useComponent('test', 'comp'))
144
+ }).toThrow('useSurfaceContext must be used within a SurfaceProvider')
145
+
146
+ consoleError.mockRestore()
147
+ })
148
+ })
@@ -0,0 +1,39 @@
1
+ /**
2
+ * useComponent - Hook to get a component from a Surface.
3
+ */
4
+
5
+ import { useMemo } from 'react'
6
+ import type { ComponentDefinition } from '../types'
7
+ import { useSurfaceContext } from '../contexts/SurfaceContext'
8
+
9
+ /**
10
+ * Gets a component from a Surface by its ID.
11
+ *
12
+ * @param surfaceId - The surface ID
13
+ * @param componentId - The component ID to look up
14
+ * @returns The ComponentDefinition, or undefined if not found
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * function MyComponent({ surfaceId, componentId }) {
19
+ * const component = useComponent(surfaceId, componentId);
20
+ *
21
+ * if (!component) {
22
+ * return null;
23
+ * }
24
+ *
25
+ * const [type, props] = Object.entries(component.component)[0];
26
+ * // Render based on type...
27
+ * }
28
+ * ```
29
+ */
30
+ export function useComponent(
31
+ surfaceId: string,
32
+ componentId: string
33
+ ): ComponentDefinition | undefined {
34
+ const { getComponent } = useSurfaceContext()
35
+
36
+ return useMemo(() => {
37
+ return getComponent(surfaceId, componentId)
38
+ }, [getComponent, surfaceId, componentId])
39
+ }