@contember/bindx-react 0.1.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 (194) hide show
  1. package/dist/batching/QueryBatcher.d.ts +31 -0
  2. package/dist/batching/QueryBatcher.d.ts.map +1 -0
  3. package/dist/batching/index.d.ts +2 -0
  4. package/dist/batching/index.d.ts.map +1 -0
  5. package/dist/components/EntityScope.d.ts +28 -0
  6. package/dist/components/EntityScope.d.ts.map +1 -0
  7. package/dist/dataview/DataGrid.d.ts +45 -0
  8. package/dist/dataview/DataGrid.d.ts.map +1 -0
  9. package/dist/dataview/DataGridPagination.d.ts +24 -0
  10. package/dist/dataview/DataGridPagination.d.ts.map +1 -0
  11. package/dist/dataview/DataGridToolbar.d.ts +16 -0
  12. package/dist/dataview/DataGridToolbar.d.ts.map +1 -0
  13. package/dist/dataview/DataViewContext.d.ts +32 -0
  14. package/dist/dataview/DataViewContext.d.ts.map +1 -0
  15. package/dist/dataview/columns.d.ts +115 -0
  16. package/dist/dataview/columns.d.ts.map +1 -0
  17. package/dist/dataview/contextHooks.d.ts +48 -0
  18. package/dist/dataview/contextHooks.d.ts.map +1 -0
  19. package/dist/dataview/dataAttribute.d.ts +6 -0
  20. package/dist/dataview/dataAttribute.d.ts.map +1 -0
  21. package/dist/dataview/export.d.ts +46 -0
  22. package/dist/dataview/export.d.ts.map +1 -0
  23. package/dist/dataview/filterComponents.d.ts +127 -0
  24. package/dist/dataview/filterComponents.d.ts.map +1 -0
  25. package/dist/dataview/filterContext.d.ts +10 -0
  26. package/dist/dataview/filterContext.d.ts.map +1 -0
  27. package/dist/dataview/filterHooks.d.ts +74 -0
  28. package/dist/dataview/filterHooks.d.ts.map +1 -0
  29. package/dist/dataview/filterScope.d.ts +54 -0
  30. package/dist/dataview/filterScope.d.ts.map +1 -0
  31. package/dist/dataview/filterWrappers.d.ts +65 -0
  32. package/dist/dataview/filterWrappers.d.ts.map +1 -0
  33. package/dist/dataview/index.d.ts +21 -0
  34. package/dist/dataview/index.d.ts.map +1 -0
  35. package/dist/dataview/infiniteLoad.d.ts +32 -0
  36. package/dist/dataview/infiniteLoad.d.ts.map +1 -0
  37. package/dist/dataview/loaderComponents.d.ts +80 -0
  38. package/dist/dataview/loaderComponents.d.ts.map +1 -0
  39. package/dist/dataview/pagingComponents.d.ts +50 -0
  40. package/dist/dataview/pagingComponents.d.ts.map +1 -0
  41. package/dist/dataview/renderCellValue.d.ts +9 -0
  42. package/dist/dataview/renderCellValue.d.ts.map +1 -0
  43. package/dist/dataview/selectionComponents.d.ts +62 -0
  44. package/dist/dataview/selectionComponents.d.ts.map +1 -0
  45. package/dist/dataview/sortingComponents.d.ts +39 -0
  46. package/dist/dataview/sortingComponents.d.ts.map +1 -0
  47. package/dist/dataview/stateStorage.d.ts +19 -0
  48. package/dist/dataview/stateStorage.d.ts.map +1 -0
  49. package/dist/dataview/useDataView.d.ts +58 -0
  50. package/dist/dataview/useDataView.d.ts.map +1 -0
  51. package/dist/dataview/useDataViewState.d.ts +89 -0
  52. package/dist/dataview/useDataViewState.d.ts.map +1 -0
  53. package/dist/hooks/BackendAdapterContext.d.ts +110 -0
  54. package/dist/hooks/BackendAdapterContext.d.ts.map +1 -0
  55. package/dist/hooks/ContemberBindxProvider.d.ts +67 -0
  56. package/dist/hooks/ContemberBindxProvider.d.ts.map +1 -0
  57. package/dist/hooks/createBindx.d.ts +39 -0
  58. package/dist/hooks/createBindx.d.ts.map +1 -0
  59. package/dist/hooks/index.d.ts +10 -0
  60. package/dist/hooks/index.d.ts.map +1 -0
  61. package/dist/hooks/useBindxEvents.d.ts +83 -0
  62. package/dist/hooks/useBindxEvents.d.ts.map +1 -0
  63. package/dist/hooks/useEntity.d.ts +123 -0
  64. package/dist/hooks/useEntity.d.ts.map +1 -0
  65. package/dist/hooks/useEntityBeforePersist.d.ts +22 -0
  66. package/dist/hooks/useEntityBeforePersist.d.ts.map +1 -0
  67. package/dist/hooks/useEntityCore.d.ts +52 -0
  68. package/dist/hooks/useEntityCore.d.ts.map +1 -0
  69. package/dist/hooks/useEntityImpl.d.ts +105 -0
  70. package/dist/hooks/useEntityImpl.d.ts.map +1 -0
  71. package/dist/hooks/useEntityList.d.ts +102 -0
  72. package/dist/hooks/useEntityList.d.ts.map +1 -0
  73. package/dist/hooks/useEntityListCore.d.ts +49 -0
  74. package/dist/hooks/useEntityListCore.d.ts.map +1 -0
  75. package/dist/hooks/useEntityListImpl.d.ts +70 -0
  76. package/dist/hooks/useEntityListImpl.d.ts.map +1 -0
  77. package/dist/hooks/useErrors.d.ts +72 -0
  78. package/dist/hooks/useErrors.d.ts.map +1 -0
  79. package/dist/hooks/usePersist.d.ts +115 -0
  80. package/dist/hooks/usePersist.d.ts.map +1 -0
  81. package/dist/hooks/useSelectionCollection.d.ts +41 -0
  82. package/dist/hooks/useSelectionCollection.d.ts.map +1 -0
  83. package/dist/hooks/useSelectionCollectionForList.d.ts +40 -0
  84. package/dist/hooks/useSelectionCollectionForList.d.ts.map +1 -0
  85. package/dist/hooks/useStableSelectionMeta.d.ts +11 -0
  86. package/dist/hooks/useStableSelectionMeta.d.ts.map +1 -0
  87. package/dist/hooks/useStoreSubscription.d.ts +19 -0
  88. package/dist/hooks/useStoreSubscription.d.ts.map +1 -0
  89. package/dist/hooks/useUndo.d.ts +45 -0
  90. package/dist/hooks/useUndo.d.ts.map +1 -0
  91. package/dist/index.d.ts +17 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/jsx/SelectionMeta.d.ts +2 -0
  94. package/dist/jsx/SelectionMeta.d.ts.map +1 -0
  95. package/dist/jsx/analyzer.d.ts +22 -0
  96. package/dist/jsx/analyzer.d.ts.map +1 -0
  97. package/dist/jsx/collectorProxy.d.ts +14 -0
  98. package/dist/jsx/collectorProxy.d.ts.map +1 -0
  99. package/dist/jsx/componentBuilder.d.ts +51 -0
  100. package/dist/jsx/componentBuilder.d.ts.map +1 -0
  101. package/dist/jsx/componentBuilder.types.d.ts +268 -0
  102. package/dist/jsx/componentBuilder.types.d.ts.map +1 -0
  103. package/dist/jsx/componentBuilderCompat.d.ts +17 -0
  104. package/dist/jsx/componentBuilderCompat.d.ts.map +1 -0
  105. package/dist/jsx/componentFactory.d.ts +39 -0
  106. package/dist/jsx/componentFactory.d.ts.map +1 -0
  107. package/dist/jsx/components/Entity.d.ts +82 -0
  108. package/dist/jsx/components/Entity.d.ts.map +1 -0
  109. package/dist/jsx/components/EntityList.d.ts +48 -0
  110. package/dist/jsx/components/EntityList.d.ts.map +1 -0
  111. package/dist/jsx/components/Field.d.ts +27 -0
  112. package/dist/jsx/components/Field.d.ts.map +1 -0
  113. package/dist/jsx/components/HasMany.d.ts +34 -0
  114. package/dist/jsx/components/HasMany.d.ts.map +1 -0
  115. package/dist/jsx/components/HasOne.d.ts +25 -0
  116. package/dist/jsx/components/HasOne.d.ts.map +1 -0
  117. package/dist/jsx/components/HasRole.d.ts +51 -0
  118. package/dist/jsx/components/HasRole.d.ts.map +1 -0
  119. package/dist/jsx/components/If.d.ts +51 -0
  120. package/dist/jsx/components/If.d.ts.map +1 -0
  121. package/dist/jsx/components/Show.d.ts +33 -0
  122. package/dist/jsx/components/Show.d.ts.map +1 -0
  123. package/dist/jsx/components/index.d.ts +8 -0
  124. package/dist/jsx/components/index.d.ts.map +1 -0
  125. package/dist/jsx/conditions.d.ts +310 -0
  126. package/dist/jsx/conditions.d.ts.map +1 -0
  127. package/dist/jsx/createComponent.d.ts +44 -0
  128. package/dist/jsx/createComponent.d.ts.map +1 -0
  129. package/dist/jsx/index.d.ts +19 -0
  130. package/dist/jsx/index.d.ts.map +1 -0
  131. package/dist/jsx/inlineProxy.d.ts +14 -0
  132. package/dist/jsx/inlineProxy.d.ts.map +1 -0
  133. package/dist/jsx/legacyTypes.d.ts +26 -0
  134. package/dist/jsx/legacyTypes.d.ts.map +1 -0
  135. package/dist/jsx/proxy.d.ts +2 -0
  136. package/dist/jsx/proxy.d.ts.map +1 -0
  137. package/dist/jsx/proxyShared.d.ts +14 -0
  138. package/dist/jsx/proxyShared.d.ts.map +1 -0
  139. package/dist/jsx/runtimeProxy.d.ts +11 -0
  140. package/dist/jsx/runtimeProxy.d.ts.map +1 -0
  141. package/dist/jsx/standaloneCreateComponent.d.ts +30 -0
  142. package/dist/jsx/standaloneCreateComponent.d.ts.map +1 -0
  143. package/dist/jsx/types.d.ts +109 -0
  144. package/dist/jsx/types.d.ts.map +1 -0
  145. package/dist/jsx/withCollector.d.ts +39 -0
  146. package/dist/jsx/withCollector.d.ts.map +1 -0
  147. package/dist/roles/RoleContext.d.ts +31 -0
  148. package/dist/roles/RoleContext.d.ts.map +1 -0
  149. package/dist/roles/index.d.ts +2 -0
  150. package/dist/roles/index.d.ts.map +1 -0
  151. package/package.json +34 -0
  152. package/src/batching/QueryBatcher.ts +114 -0
  153. package/src/batching/index.ts +1 -0
  154. package/src/components/EntityScope.tsx +43 -0
  155. package/src/hooks/BackendAdapterContext.tsx +214 -0
  156. package/src/hooks/ContemberBindxProvider.tsx +158 -0
  157. package/src/hooks/index.ts +61 -0
  158. package/src/hooks/useBindxEvents.ts +229 -0
  159. package/src/hooks/useEntity.ts +489 -0
  160. package/src/hooks/useEntityBeforePersist.ts +32 -0
  161. package/src/hooks/useEntityList.ts +444 -0
  162. package/src/hooks/useErrors.ts +150 -0
  163. package/src/hooks/usePersist.ts +377 -0
  164. package/src/hooks/useSelectionCollection.ts +122 -0
  165. package/src/hooks/useStoreSubscription.ts +51 -0
  166. package/src/hooks/useUndo.ts +83 -0
  167. package/src/index.ts +357 -0
  168. package/src/jsx/SelectionMeta.ts +2 -0
  169. package/src/jsx/analyzer.ts +261 -0
  170. package/src/jsx/collectorProxy.ts +282 -0
  171. package/src/jsx/componentBuilder.ts +180 -0
  172. package/src/jsx/componentBuilder.types.ts +463 -0
  173. package/src/jsx/componentBuilderCompat.ts +39 -0
  174. package/src/jsx/componentFactory.ts +400 -0
  175. package/src/jsx/components/Entity.tsx +383 -0
  176. package/src/jsx/components/EntityList.tsx +147 -0
  177. package/src/jsx/components/Field.tsx +79 -0
  178. package/src/jsx/components/HasMany.tsx +109 -0
  179. package/src/jsx/components/HasOne.tsx +78 -0
  180. package/src/jsx/components/HasRole.tsx +95 -0
  181. package/src/jsx/components/If.tsx +127 -0
  182. package/src/jsx/components/Show.tsx +56 -0
  183. package/src/jsx/components/index.ts +20 -0
  184. package/src/jsx/conditions.ts +472 -0
  185. package/src/jsx/createComponent.ts +175 -0
  186. package/src/jsx/index.ts +135 -0
  187. package/src/jsx/legacyTypes.ts +30 -0
  188. package/src/jsx/proxy.ts +1 -0
  189. package/src/jsx/proxyShared.ts +42 -0
  190. package/src/jsx/standaloneCreateComponent.ts +47 -0
  191. package/src/jsx/types.ts +192 -0
  192. package/src/jsx/withCollector.ts +45 -0
  193. package/src/roles/RoleContext.tsx +57 -0
  194. package/src/roles/index.ts +1 -0
@@ -0,0 +1,489 @@
1
+ import { useRef, useEffect, useMemo, useCallback } from 'react'
2
+ import type {
3
+ EntityDef,
4
+ EntityUniqueWhere,
5
+ SchemaRegistry,
6
+ SelectionInput,
7
+ SelectionMeta,
8
+ FieldError,
9
+ EntitySnapshot,
10
+ LoadStatus,
11
+ SelectedEntityFields,
12
+ CommonEntity,
13
+ EntityForRoles,
14
+ RoleNames,
15
+ } from '@contember/bindx'
16
+ import {
17
+ EntityHandle,
18
+ resolveSelectionMeta,
19
+ buildQueryFromSelection,
20
+ setEntityData,
21
+ setLoadState,
22
+ createLoadError,
23
+ } from '@contember/bindx'
24
+ import { useBindxContext, useSchemaRegistry } from './BackendAdapterContext.js'
25
+ import { useStoreSubscription } from './useStoreSubscription.js'
26
+
27
+ // ============================================================================
28
+ // Options
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Options for useEntity hook
33
+ */
34
+ export interface UseEntityOptions {
35
+ /** Unique field(s) to identify the entity (e.g., { id: '...' } or { slug: '...' }) */
36
+ by: EntityUniqueWhere
37
+ /** If true, use cached data from store if available (default: false) */
38
+ cache?: boolean
39
+ /**
40
+ * Pre-resolved selection metadata.
41
+ * When provided, definer (3rd argument) is not needed.
42
+ * Used internally by Entity component.
43
+ */
44
+ selection?: SelectionMeta
45
+ /** Optional query key for cache invalidation */
46
+ queryKey?: string
47
+ }
48
+
49
+ // ============================================================================
50
+ // Result types
51
+ // ============================================================================
52
+
53
+ /**
54
+ * Loading state for entity accessor
55
+ */
56
+ export interface LoadingEntityAccessor {
57
+ readonly status: 'loading'
58
+ readonly isLoading: true
59
+ readonly isError: false
60
+ readonly isNotFound: false
61
+ readonly isPersisting: false
62
+ readonly isDirty: false
63
+ readonly id: string
64
+ readonly fields: never
65
+ readonly data: never
66
+ persist(): Promise<void>
67
+ reset(): void
68
+ }
69
+
70
+ /**
71
+ * Error state for entity accessor
72
+ */
73
+ export interface ErrorEntityAccessor {
74
+ readonly status: 'error'
75
+ readonly isLoading: false
76
+ readonly isError: true
77
+ readonly isNotFound: false
78
+ readonly error: FieldError
79
+ readonly isPersisting: false
80
+ readonly isDirty: false
81
+ readonly id: string
82
+ readonly fields: never
83
+ readonly data: never
84
+ persist(): Promise<void>
85
+ reset(): void
86
+ }
87
+
88
+ /**
89
+ * Not found state for entity accessor
90
+ */
91
+ export interface NotFoundEntityAccessor {
92
+ readonly status: 'not_found'
93
+ readonly isLoading: false
94
+ readonly isError: false
95
+ readonly isNotFound: true
96
+ readonly isPersisting: false
97
+ readonly isDirty: false
98
+ readonly id: string
99
+ readonly fields: never
100
+ readonly data: never
101
+ persist(): Promise<void>
102
+ reset(): void
103
+ }
104
+
105
+ /**
106
+ * Ready state base interface for entity accessor
107
+ */
108
+ export interface ReadyEntityAccessorBase<TEntity extends object, TSelected extends object = TEntity> {
109
+ readonly status: 'ready'
110
+ readonly isLoading: false
111
+ readonly isError: false
112
+ readonly isNotFound: false
113
+ readonly isPersisting: boolean
114
+ readonly isDirty: boolean
115
+ readonly id: string
116
+ readonly fields: SelectedEntityFields<TEntity, TSelected>
117
+ readonly data: TSelected
118
+ persist(): Promise<void>
119
+ reset(): void
120
+ }
121
+
122
+ /**
123
+ * Ready state for entity accessor with direct field access via Proxy.
124
+ */
125
+ export type ReadyEntityAccessor<TEntity extends object, TSelected extends object = TEntity> =
126
+ ReadyEntityAccessorBase<TEntity, TSelected> & SelectedEntityFields<TEntity, TSelected>
127
+
128
+ /**
129
+ * Union of all entity accessor states
130
+ */
131
+ export type EntityAccessorResult<TEntity extends object, TSelected extends object = TEntity> =
132
+ | LoadingEntityAccessor
133
+ | ErrorEntityAccessor
134
+ | NotFoundEntityAccessor
135
+ | ReadyEntityAccessor<TEntity, TSelected>
136
+
137
+ // ============================================================================
138
+ // Internal helpers
139
+ // ============================================================================
140
+
141
+ function createLoadingAccessor(id: string): LoadingEntityAccessor {
142
+ return {
143
+ status: 'loading',
144
+ isLoading: true,
145
+ isError: false,
146
+ isNotFound: false,
147
+ isPersisting: false,
148
+ isDirty: false,
149
+ id,
150
+ get fields(): never {
151
+ throw new Error('Cannot access fields while loading')
152
+ },
153
+ get data(): never {
154
+ throw new Error('Cannot access data while loading')
155
+ },
156
+ async persist() {
157
+ // No-op while loading
158
+ },
159
+ reset() {
160
+ // No-op while loading
161
+ },
162
+ }
163
+ }
164
+
165
+ function createErrorAccessor(id: string, error: FieldError): ErrorEntityAccessor {
166
+ return {
167
+ status: 'error',
168
+ isLoading: false,
169
+ isError: true,
170
+ isNotFound: false,
171
+ error,
172
+ isPersisting: false,
173
+ isDirty: false,
174
+ id,
175
+ get fields(): never {
176
+ throw new Error('Cannot access fields after error')
177
+ },
178
+ get data(): never {
179
+ throw new Error('Cannot access data after error')
180
+ },
181
+ async persist() {
182
+ // No-op after error
183
+ },
184
+ reset() {
185
+ // No-op after error
186
+ },
187
+ }
188
+ }
189
+
190
+ function createNotFoundAccessor(id: string): NotFoundEntityAccessor {
191
+ return {
192
+ status: 'not_found',
193
+ isLoading: false,
194
+ isError: false,
195
+ isNotFound: true,
196
+ isPersisting: false,
197
+ isDirty: false,
198
+ id,
199
+ get fields(): never {
200
+ throw new Error('Cannot access fields — entity not found')
201
+ },
202
+ get data(): never {
203
+ throw new Error('Cannot access data — entity not found')
204
+ },
205
+ async persist() {
206
+ // No-op for not found
207
+ },
208
+ reset() {
209
+ // No-op for not found
210
+ },
211
+ }
212
+ }
213
+
214
+ // ============================================================================
215
+ // Hook overloads
216
+ // ============================================================================
217
+
218
+ /**
219
+ * Hook to fetch and manage a single entity with role-expanded type inference.
220
+ *
221
+ * @example
222
+ * ```tsx
223
+ * const article = useEntity(schema.Article, { by: { id }, roles: ['admin'] }, e => e.title().internalNotes())
224
+ * ```
225
+ */
226
+ export function useEntity<
227
+ TRoleMap extends Record<string, object>,
228
+ TRoles extends RoleNames<TRoleMap>,
229
+ TResult extends object,
230
+ >(
231
+ entity: EntityDef<TRoleMap>,
232
+ options: UseEntityOptions & { roles: readonly TRoles[] },
233
+ definer: SelectionInput<EntityForRoles<TRoleMap, TRoles>, TResult>,
234
+ ): EntityAccessorResult<EntityForRoles<TRoleMap, TRoles>, TResult>
235
+
236
+ /**
237
+ * Hook to fetch and manage a single entity with full type inference.
238
+ * Uses the common (narrowest) entity type when no roles are specified.
239
+ *
240
+ * @example
241
+ * ```tsx
242
+ * const article = useEntity(schema.Article, { by: { id } }, e => e.title().content())
243
+ * if (article.status !== 'ready') return <Loading />
244
+ * return <input value={article.title.value} onChange={...} />
245
+ * ```
246
+ */
247
+ export function useEntity<TRoleMap extends Record<string, object>, TResult extends object>(
248
+ entity: EntityDef<TRoleMap>,
249
+ options: UseEntityOptions,
250
+ definer: SelectionInput<CommonEntity<TRoleMap>, TResult>,
251
+ ): EntityAccessorResult<CommonEntity<TRoleMap>, TResult>
252
+
253
+ /**
254
+ * Hook to fetch and manage a single entity with pre-resolved selection.
255
+ *
256
+ * Used internally by Entity component that collects selection from JSX.
257
+ */
258
+ export function useEntity(
259
+ entity: EntityDef,
260
+ options: UseEntityOptions & { selection: SelectionMeta },
261
+ ): EntityAccessorResult<object, object>
262
+
263
+ // ============================================================================
264
+ // Implementation
265
+ // ============================================================================
266
+
267
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
268
+ export function useEntity(
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
+ entity: EntityDef<any>,
271
+ options: UseEntityOptions,
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
273
+ definer?: SelectionInput<any, any>,
274
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
275
+ ): EntityAccessorResult<any, any> {
276
+ const schemaRegistry = useSchemaRegistry()
277
+ const entityType = entity.$name
278
+ const { store, dispatcher, batcher, batchPersister } = useBindxContext()
279
+
280
+ // --- Selection resolution ---
281
+ const resolvedMeta = definer ? resolveSelectionMeta(definer) : null
282
+ const definerQueryKey = resolvedMeta ? JSON.stringify(buildQueryFromSelection(resolvedMeta)) : null
283
+ const selectionRef = useRef<{ meta: SelectionMeta; queryKey: string } | null>(null)
284
+
285
+ if (definerQueryKey && resolvedMeta) {
286
+ if (!selectionRef.current || selectionRef.current.queryKey !== definerQueryKey) {
287
+ selectionRef.current = { meta: resolvedMeta, queryKey: definerQueryKey }
288
+ }
289
+ }
290
+
291
+ const selectionMeta = definer ? selectionRef.current!.meta : options.selection!
292
+
293
+ // --- Derive ID from 'by' ---
294
+ const by = options.by
295
+ const id = useMemo(() => {
296
+ if ('id' in by && typeof by['id'] === 'string') {
297
+ return by['id']
298
+ }
299
+ const firstValue = Object.values(by)[0]
300
+ return typeof firstValue === 'string' ? firstValue : String(firstValue)
301
+ }, [by])
302
+
303
+ const byKey = useMemo(() => JSON.stringify(by), [by])
304
+ const byRef = useRef(by)
305
+ byRef.current = by
306
+
307
+ // --- Effective query key ---
308
+ const effectiveQueryKey = useMemo(() => {
309
+ if (options.queryKey) return options.queryKey
310
+ const query = buildQueryFromSelection(selectionMeta)
311
+ return JSON.stringify(query)
312
+ }, [options.queryKey, selectionMeta])
313
+
314
+ // --- Store subscription ---
315
+ const subscribe = useCallback(
316
+ (callback: () => void) => {
317
+ return store.subscribeToEntity(entityType, id, callback)
318
+ },
319
+ [store, entityType, id],
320
+ )
321
+
322
+ interface SubscriptionSnapshot {
323
+ snapshot: EntitySnapshot | undefined
324
+ loadState: { status: LoadStatus; error?: FieldError } | undefined
325
+ isPersisting: boolean
326
+ }
327
+
328
+ const getSnapshot = useCallback((): SubscriptionSnapshot => {
329
+ return {
330
+ snapshot: store.getEntitySnapshot(entityType, id),
331
+ loadState: store.getLoadState(entityType, id),
332
+ isPersisting: store.isPersisting(entityType, id),
333
+ }
334
+ }, [store, entityType, id])
335
+
336
+ const isEqual = useCallback((a: SubscriptionSnapshot, b: SubscriptionSnapshot): boolean => {
337
+ return (
338
+ a.snapshot === b.snapshot &&
339
+ a.loadState?.status === b.loadState?.status &&
340
+ a.isPersisting === b.isPersisting
341
+ )
342
+ }, [])
343
+
344
+ const { snapshot, loadState, isPersisting } = useStoreSubscription({
345
+ subscribe,
346
+ getSnapshot,
347
+ isEqual,
348
+ })
349
+
350
+ // --- Data loading ---
351
+ const fetchingRef = useRef<string | null>(null)
352
+
353
+ useEffect(() => {
354
+ // Check cache first
355
+ if (options.cache && store.hasEntity(entityType, id)) {
356
+ dispatcher.dispatch(setLoadState(entityType, id, 'success'))
357
+ return
358
+ }
359
+
360
+ // Skip if already fetching same data
361
+ const fetchKey = `${entityType}:${byKey}:${effectiveQueryKey}`
362
+ if (fetchingRef.current === fetchKey) {
363
+ return
364
+ }
365
+ fetchingRef.current = fetchKey
366
+
367
+ const abortController = new AbortController()
368
+
369
+ dispatcher.dispatch(setLoadState(entityType, id, 'loading'))
370
+
371
+ const fetchData = async (): Promise<void> => {
372
+ try {
373
+ const spec = buildQueryFromSelection(selectionMeta)
374
+ const currentBy = byRef.current
375
+ const result = await batcher.enqueue(
376
+ { type: 'get', entityType, by: currentBy, spec },
377
+ { signal: abortController.signal },
378
+ )
379
+
380
+ if (abortController.signal.aborted) return
381
+
382
+ if (result.type === 'get' && result.data === null) {
383
+ dispatcher.dispatch(setLoadState(entityType, id, 'not_found'))
384
+ } else if (result.type === 'get' && result.data) {
385
+ dispatcher.dispatch(
386
+ setEntityData(entityType, id, result.data, true),
387
+ )
388
+ dispatcher.dispatch(setLoadState(entityType, id, 'success'))
389
+ }
390
+ } catch (error) {
391
+ if (abortController.signal.aborted) return
392
+ if (error instanceof Error && error.name === 'AbortError') return
393
+
394
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
395
+ dispatcher.dispatch(
396
+ setLoadState(entityType, id, 'error', createLoadError(normalizedError)),
397
+ )
398
+ }
399
+ }
400
+
401
+ fetchData()
402
+
403
+ return () => {
404
+ abortController.abort()
405
+ if (fetchingRef.current === fetchKey) {
406
+ fetchingRef.current = null
407
+ }
408
+ }
409
+ }, [entityType, id, byKey, effectiveQueryKey, options.cache, batcher, store, dispatcher, selectionMeta])
410
+
411
+ // --- EntityHandle ---
412
+ // Include snapshot in deps so handle reference changes when entity data changes.
413
+ // This ensures memo-wrapped children that receive individual field handles re-render.
414
+ const handle = useMemo(
415
+ () => EntityHandle.create(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>),
416
+ [id, entityType, store, dispatcher, schemaRegistry, snapshot],
417
+ )
418
+
419
+ useEffect(() => {
420
+ return () => {
421
+ handle.dispose()
422
+ }
423
+ }, [handle])
424
+
425
+ // --- Build accessor ---
426
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
427
+ const accessor = useMemo((): EntityAccessorResult<any, any> => {
428
+ if (!loadState || loadState.status === 'loading') {
429
+ return createLoadingAccessor(id)
430
+ }
431
+
432
+ if (loadState.status === 'error') {
433
+ return createErrorAccessor(id, loadState.error!)
434
+ }
435
+
436
+ if (loadState.status === 'not_found') {
437
+ return createNotFoundAccessor(id)
438
+ }
439
+
440
+ if (!snapshot) {
441
+ return createLoadingAccessor(id)
442
+ }
443
+
444
+ // Ready state
445
+ const realId = (snapshot.data as Record<string, unknown>)?.['id'] as string | undefined ?? id
446
+
447
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
448
+ const baseAccessor: ReadyEntityAccessorBase<any, any> = {
449
+ status: 'ready',
450
+ isLoading: false,
451
+ isError: false,
452
+ isNotFound: false,
453
+ isPersisting,
454
+ get isDirty() {
455
+ return handle.$isDirty
456
+ },
457
+ id: realId,
458
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
459
+ fields: handle.$fields as any,
460
+ data: snapshot.data,
461
+ async persist() {
462
+ await batchPersister.persist(entityType, realId)
463
+ },
464
+ reset() {
465
+ handle.reset()
466
+ },
467
+ }
468
+
469
+ // Wrap in Proxy for direct field access
470
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
471
+ return new Proxy(baseAccessor as any, {
472
+ get(target: Record<string, unknown>, prop: string | symbol, receiver: unknown) {
473
+ if (prop in target) {
474
+ return Reflect.get(target, prop, receiver)
475
+ }
476
+ if (typeof prop === 'string') {
477
+ const fields = target['fields'] as Record<string, unknown>
478
+ const fieldValue = fields[prop]
479
+ if (fieldValue !== undefined) {
480
+ return fieldValue
481
+ }
482
+ }
483
+ return undefined
484
+ },
485
+ })
486
+ }, [snapshot, loadState, isPersisting, id, handle, batchPersister, entityType])
487
+
488
+ return accessor
489
+ }
@@ -0,0 +1,32 @@
1
+ import { useInterceptEntity } from './useBindxEvents.js'
2
+
3
+ /**
4
+ * Convenience hook to run a callback before an entity is persisted.
5
+ *
6
+ * This is useful for validation, cleanup, or other pre-persist logic.
7
+ * The callback is called before the entity is sent to the server.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * function ArticleForm({ article }: { article: EntityRef<Article> }) {
12
+ * useEntityBeforePersist('Article', article.id, () => {
13
+ * // Validate or perform cleanup before persist
14
+ * if (!article.$fields.title.value) {
15
+ * article.$fields.title.addError('Title is required')
16
+ * }
17
+ * })
18
+ *
19
+ * return <form>...</form>
20
+ * }
21
+ * ```
22
+ */
23
+ export function useEntityBeforePersist(
24
+ entityType: string,
25
+ entityId: string,
26
+ callback: () => void,
27
+ ): void {
28
+ useInterceptEntity('entity:persisting', entityType, entityId, () => {
29
+ callback()
30
+ return { action: 'continue' }
31
+ })
32
+ }