@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,377 @@
1
+ import { useSyncExternalStore, useCallback, useMemo, useRef } from 'react'
2
+ import {
3
+ FIELD_REF_META,
4
+ type BatchPersister,
5
+ type DirtyEntity,
6
+ type EntityPersistResult,
7
+ type PersistenceResult,
8
+ type PersistScope,
9
+ type BatchPersistOptions,
10
+ type FieldRef,
11
+ type HasOneRef,
12
+ type HasManyRef,
13
+ type FieldRefMeta,
14
+ } from '@contember/bindx'
15
+ import { useBindxContext } from './BackendAdapterContext.js'
16
+
17
+ /**
18
+ * Any ref that has entity metadata (FieldRef, HasOneRef, HasManyRef)
19
+ */
20
+ export type AnyRefWithMeta = { readonly [FIELD_REF_META]: FieldRefMeta }
21
+
22
+ /**
23
+ * Extracts entity metadata from a ref
24
+ */
25
+ function getRefMeta(ref: AnyRefWithMeta): FieldRefMeta {
26
+ return ref[FIELD_REF_META]
27
+ }
28
+
29
+ /**
30
+ * API returned by usePersist hook
31
+ */
32
+ export interface PersistApi {
33
+ /** Persist all dirty entities in a transaction */
34
+ persistAll(options?: BatchPersistOptions): Promise<PersistenceResult>
35
+
36
+ /**
37
+ * Persist a single entity.
38
+ * Can be called with entityType/entityId or with a ref (FieldRef, HasOneRef, HasManyRef).
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * // With entityType and entityId
43
+ * persist('Article', 'abc-123')
44
+ *
45
+ * // With a FieldRef - persists the entity the field belongs to
46
+ * persist(article.fields.title)
47
+ * ```
48
+ */
49
+ persist(ref: AnyRefWithMeta, options?: BatchPersistOptions): Promise<EntityPersistResult>
50
+ persist(entityType: string, entityId: string, options?: BatchPersistOptions): Promise<EntityPersistResult>
51
+
52
+ /**
53
+ * Persist specific fields of an entity.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * // With entityType, entityId, and field names
58
+ * persistFields('Article', 'abc-123', ['title', 'content'])
59
+ *
60
+ * // With a FieldRef - persists just that field
61
+ * persistFields(article.fields.title)
62
+ * ```
63
+ */
64
+ persistFields(ref: AnyRefWithMeta, options?: BatchPersistOptions): Promise<EntityPersistResult>
65
+ persistFields(
66
+ entityType: string,
67
+ entityId: string,
68
+ fields: readonly string[],
69
+ options?: BatchPersistOptions,
70
+ ): Promise<EntityPersistResult>
71
+
72
+ /** Persist with custom scope */
73
+ persistScope(scope: PersistScope, options?: BatchPersistOptions): Promise<PersistenceResult>
74
+
75
+ /** Check if any entity is currently being persisted */
76
+ isPersisting: boolean
77
+
78
+ /** Get all dirty entities */
79
+ dirtyEntities: readonly DirtyEntity[]
80
+
81
+ /** Check if there are any dirty entities */
82
+ isDirty: boolean
83
+ }
84
+
85
+ /**
86
+ * Hook for accessing the persistence API.
87
+ * Provides methods to persist all dirty entities, a single entity, or specific fields.
88
+ *
89
+ * @example
90
+ * ```tsx
91
+ * function SaveButton() {
92
+ * const { persistAll, isPersisting, isDirty } = usePersist()
93
+ *
94
+ * return (
95
+ * <button
96
+ * onClick={() => persistAll()}
97
+ * disabled={isPersisting || !isDirty}
98
+ * >
99
+ * {isPersisting ? 'Saving...' : 'Save All'}
100
+ * </button>
101
+ * )
102
+ * }
103
+ * ```
104
+ */
105
+ export function usePersist(): PersistApi {
106
+ const { batchPersister, store } = useBindxContext()
107
+
108
+ if (!batchPersister) {
109
+ throw new Error('usePersist requires BatchPersister to be configured in the context')
110
+ }
111
+
112
+ const changeRegistry = batchPersister.getChangeRegistry()
113
+
114
+ // Cache for dirty entities snapshot to avoid infinite loops
115
+ const dirtyEntitiesCacheRef = useRef<readonly DirtyEntity[]>([])
116
+
117
+ // Subscribe to store changes for dirty tracking
118
+ const dirtyEntities = useSyncExternalStore(
119
+ useCallback((callback) => store.subscribe(callback), [store]),
120
+ useCallback(() => {
121
+ const newDirtyEntities = changeRegistry.getDirtyEntities()
122
+ // Compare by length and entity keys to avoid unnecessary updates
123
+ const cached = dirtyEntitiesCacheRef.current
124
+ if (
125
+ cached.length === newDirtyEntities.length &&
126
+ cached.every((e, i) => {
127
+ const newE = newDirtyEntities[i]
128
+ return newE && e.entityType === newE.entityType && e.entityId === newE.entityId
129
+ })
130
+ ) {
131
+ return cached
132
+ }
133
+ dirtyEntitiesCacheRef.current = newDirtyEntities
134
+ return newDirtyEntities
135
+ }, [changeRegistry]),
136
+ useCallback(() => changeRegistry.getDirtyEntities(), [changeRegistry]),
137
+ )
138
+
139
+ // Cache for isPersisting snapshot
140
+ const isPersistingCacheRef = useRef<boolean>(false)
141
+
142
+ // Subscribe to in-flight changes
143
+ const isPersisting = useSyncExternalStore(
144
+ useCallback((callback) => changeRegistry.subscribe(callback), [changeRegistry]),
145
+ useCallback(() => {
146
+ const newIsPersisting = changeRegistry.hasInFlight()
147
+ if (isPersistingCacheRef.current === newIsPersisting) {
148
+ return isPersistingCacheRef.current
149
+ }
150
+ isPersistingCacheRef.current = newIsPersisting
151
+ return newIsPersisting
152
+ }, [changeRegistry]),
153
+ useCallback(() => changeRegistry.hasInFlight(), [changeRegistry]),
154
+ )
155
+
156
+ const persistAll = useCallback(
157
+ (options?: BatchPersistOptions) => batchPersister.persistAll(options),
158
+ [batchPersister],
159
+ )
160
+
161
+ const persist = useCallback(
162
+ (
163
+ refOrEntityType: AnyRefWithMeta | string,
164
+ entityIdOrOptions?: string | BatchPersistOptions,
165
+ options?: BatchPersistOptions,
166
+ ) => {
167
+ if (typeof refOrEntityType === 'string') {
168
+ // Called with (entityType, entityId, options?)
169
+ return batchPersister.persist(refOrEntityType, entityIdOrOptions as string, options)
170
+ }
171
+ // Called with (ref, options?)
172
+ const meta = getRefMeta(refOrEntityType)
173
+ return batchPersister.persist(meta.entityType, meta.entityId, entityIdOrOptions as BatchPersistOptions | undefined)
174
+ },
175
+ [batchPersister],
176
+ )
177
+
178
+ const persistFields = useCallback(
179
+ (
180
+ refOrEntityType: AnyRefWithMeta | string,
181
+ entityIdOrOptions?: string | BatchPersistOptions,
182
+ fieldsOrOptions?: readonly string[] | BatchPersistOptions,
183
+ options?: BatchPersistOptions,
184
+ ) => {
185
+ if (typeof refOrEntityType === 'string') {
186
+ // Called with (entityType, entityId, fields, options?)
187
+ return batchPersister.persistFields(
188
+ refOrEntityType,
189
+ entityIdOrOptions as string,
190
+ fieldsOrOptions as readonly string[],
191
+ options,
192
+ )
193
+ }
194
+ // Called with (ref, options?) - persist just this field
195
+ const meta = getRefMeta(refOrEntityType)
196
+ return batchPersister.persistFields(
197
+ meta.entityType,
198
+ meta.entityId,
199
+ [meta.fieldName],
200
+ entityIdOrOptions as BatchPersistOptions | undefined,
201
+ )
202
+ },
203
+ [batchPersister],
204
+ )
205
+
206
+ const persistScope = useCallback(
207
+ (scope: PersistScope, options?: BatchPersistOptions) =>
208
+ batchPersister.persistScope(scope, options),
209
+ [batchPersister],
210
+ )
211
+
212
+ return useMemo(
213
+ () => ({
214
+ persistAll,
215
+ persist,
216
+ persistFields,
217
+ persistScope,
218
+ isPersisting,
219
+ dirtyEntities,
220
+ isDirty: dirtyEntities.length > 0,
221
+ }),
222
+ [persistAll, persist, persistFields, persistScope, isPersisting, dirtyEntities],
223
+ )
224
+ }
225
+
226
+ /**
227
+ * API returned by usePersistEntity hook
228
+ */
229
+ export interface EntityPersistApi {
230
+ /** Persist this entity */
231
+ persist(options?: BatchPersistOptions): Promise<EntityPersistResult>
232
+
233
+ /** Persist specific fields of this entity */
234
+ persistFields(fields: readonly string[], options?: BatchPersistOptions): Promise<EntityPersistResult>
235
+
236
+ /** Check if this entity is currently being persisted */
237
+ isPersisting: boolean
238
+
239
+ /** Check if this entity has any dirty changes */
240
+ isDirty: boolean
241
+
242
+ /** Get dirty fields for this entity */
243
+ dirtyFields: readonly string[]
244
+
245
+ /** Get dirty relations for this entity */
246
+ dirtyRelations: readonly string[]
247
+ }
248
+
249
+ /**
250
+ * Hook for accessing persistence API for a specific entity.
251
+ * Provides convenience methods scoped to a single entity.
252
+ *
253
+ * @example
254
+ * ```tsx
255
+ * function ArticleEditor({ id }: { id: string }) {
256
+ * const article = useEntity('Article', { id })
257
+ * const { persist, persistFields, isPersisting, isDirty } = usePersistEntity('Article', id)
258
+ *
259
+ * return (
260
+ * <div>
261
+ * <input {...article.fields.title.inputProps} />
262
+ * <button onClick={() => persistFields(['title'])} disabled={isPersisting}>
263
+ * Save Title
264
+ * </button>
265
+ * <button onClick={() => persist()} disabled={isPersisting || !isDirty}>
266
+ * Save All
267
+ * </button>
268
+ * </div>
269
+ * )
270
+ * }
271
+ * ```
272
+ */
273
+ export function usePersistEntity(entityType: string, entityId: string): EntityPersistApi {
274
+ const { batchPersister, store } = useBindxContext()
275
+
276
+ if (!batchPersister) {
277
+ throw new Error('usePersistEntity requires BatchPersister to be configured in the context')
278
+ }
279
+
280
+ const changeRegistry = batchPersister.getChangeRegistry()
281
+
282
+ // Caches for snapshot stability
283
+ const dirtyFieldsCacheRef = useRef<readonly string[]>([])
284
+ const dirtyRelationsCacheRef = useRef<readonly string[]>([])
285
+ const isPersistingCacheRef = useRef<boolean>(false)
286
+
287
+ const dirtyFields = useSyncExternalStore(
288
+ useCallback(
289
+ (callback) => store.subscribeToEntity(entityType, entityId, callback),
290
+ [store, entityType, entityId],
291
+ ),
292
+ useCallback(() => {
293
+ const newDirtyFields = store.getDirtyFields(entityType, entityId)
294
+ const cached = dirtyFieldsCacheRef.current
295
+ if (
296
+ cached.length === newDirtyFields.length &&
297
+ cached.every((f, i) => f === newDirtyFields[i])
298
+ ) {
299
+ return cached
300
+ }
301
+ dirtyFieldsCacheRef.current = newDirtyFields
302
+ return newDirtyFields
303
+ }, [store, entityType, entityId]),
304
+ useCallback(() => store.getDirtyFields(entityType, entityId), [store, entityType, entityId]),
305
+ )
306
+
307
+ const dirtyRelations = useSyncExternalStore(
308
+ useCallback(
309
+ (callback) => store.subscribeToEntity(entityType, entityId, callback),
310
+ [store, entityType, entityId],
311
+ ),
312
+ useCallback(() => {
313
+ const newDirtyRelations = store.getDirtyRelations(entityType, entityId)
314
+ const cached = dirtyRelationsCacheRef.current
315
+ if (
316
+ cached.length === newDirtyRelations.length &&
317
+ cached.every((r, i) => r === newDirtyRelations[i])
318
+ ) {
319
+ return cached
320
+ }
321
+ dirtyRelationsCacheRef.current = newDirtyRelations
322
+ return newDirtyRelations
323
+ }, [store, entityType, entityId]),
324
+ useCallback(() => store.getDirtyRelations(entityType, entityId), [store, entityType, entityId]),
325
+ )
326
+
327
+ const isPersisting = useSyncExternalStore(
328
+ useCallback(
329
+ (callback) => {
330
+ const unsubStore = store.subscribeToEntity(entityType, entityId, callback)
331
+ const unsubRegistry = changeRegistry.subscribe(callback)
332
+ return () => {
333
+ unsubStore()
334
+ unsubRegistry()
335
+ }
336
+ },
337
+ [store, changeRegistry, entityType, entityId],
338
+ ),
339
+ useCallback(() => {
340
+ const newIsPersisting = changeRegistry.isInFlight(entityType, entityId)
341
+ if (isPersistingCacheRef.current === newIsPersisting) {
342
+ return isPersistingCacheRef.current
343
+ }
344
+ isPersistingCacheRef.current = newIsPersisting
345
+ return newIsPersisting
346
+ }, [changeRegistry, entityType, entityId]),
347
+ useCallback(
348
+ () => changeRegistry.isInFlight(entityType, entityId),
349
+ [changeRegistry, entityType, entityId],
350
+ ),
351
+ )
352
+
353
+ const persist = useCallback(
354
+ (options?: BatchPersistOptions) => batchPersister.persist(entityType, entityId, options),
355
+ [batchPersister, entityType, entityId],
356
+ )
357
+
358
+ const persistFields = useCallback(
359
+ (fields: readonly string[], options?: BatchPersistOptions) =>
360
+ batchPersister.persistFields(entityType, entityId, fields, options),
361
+ [batchPersister, entityType, entityId],
362
+ )
363
+
364
+ const isDirty = dirtyFields.length > 0 || dirtyRelations.length > 0
365
+
366
+ return useMemo(
367
+ () => ({
368
+ persist,
369
+ persistFields,
370
+ isPersisting,
371
+ isDirty,
372
+ dirtyFields,
373
+ dirtyRelations,
374
+ }),
375
+ [persist, persistFields, isPersisting, isDirty, dirtyFields, dirtyRelations],
376
+ )
377
+ }
@@ -0,0 +1,122 @@
1
+ import { useContext, useMemo, useRef, type ReactNode } from 'react'
2
+ import { createCollectorProxy } from '../jsx/proxy.js'
3
+ import { collectSelection, debugSelection } from '../jsx/analyzer.js'
4
+ import { mergeSelections } from '../jsx/SelectionMeta.js'
5
+ import { buildQueryFromSelection, SelectionScope } from '@contember/bindx'
6
+ import type { SelectionMeta, SchemaRegistry } from '@contember/bindx'
7
+ import type { EntityAccessor } from '../jsx/types.js'
8
+ import { BindxContext } from './BackendAdapterContext.js'
9
+
10
+ /**
11
+ * Options for useSelectionCollection hook.
12
+ */
13
+ export interface UseSelectionCollectionOptions {
14
+ /** Entity type name (for debugging) */
15
+ entityType: string
16
+ /** Stable key for useMemo dependencies (e.g., entityId or JSON-serialized options) */
17
+ depsKey: string
18
+ /** Collector function — receives a collector proxy, returns JSX for analysis */
19
+ collect: (collector: EntityAccessor<unknown>) => ReactNode
20
+ /** Extra data to include in the query key (e.g., filter/orderBy for lists) */
21
+ queryKeyExtra?: Record<string, unknown>
22
+ }
23
+
24
+ /**
25
+ * Result from useSelectionCollection hook.
26
+ */
27
+ export interface SelectionCollectionResult {
28
+ /** Selection metadata for query building */
29
+ selection: SelectionMeta
30
+ /** Stable query key for dependency tracking */
31
+ queryKey: string
32
+ }
33
+
34
+ /**
35
+ * Cached selection data
36
+ */
37
+ interface SelectionCache {
38
+ selection: SelectionMeta
39
+ queryKey: string
40
+ }
41
+
42
+ /**
43
+ * Hook for collecting field selection from JSX children.
44
+ * Implements the two-phase rendering approach:
45
+ * 1. Creates a collector proxy
46
+ * 2. Calls the collect function with the proxy to gather field accesses
47
+ * 3. Analyzes returned JSX for component-level selections
48
+ * 4. Merges both sources of selection
49
+ *
50
+ * **Limitation:** Selection is collected once per `depsKey`.
51
+ * Fields accessed conditionally in the collect function are only captured
52
+ * if they are accessed during the initial collection phase.
53
+ *
54
+ * @internal This hook is for internal use only.
55
+ */
56
+ export function useSelectionCollection(
57
+ options: UseSelectionCollectionOptions,
58
+ ): SelectionCollectionResult {
59
+ const { entityType, depsKey, collect, queryKeyExtra } = options
60
+ const bindxContext = useContext(BindxContext)
61
+
62
+ // Stable collect ref - we use a ref to avoid re-running useMemo on every render
63
+ const collectRef = useRef(collect)
64
+ collectRef.current = collect
65
+
66
+ // Cache for selection to avoid unnecessary refetches
67
+ const selectionCacheRef = useRef<SelectionCache | null>(null)
68
+
69
+ const schemaRegistry = bindxContext?.schema ?? null
70
+
71
+ // Collection phase - runs on every render but caches based on content
72
+ const result = useMemo((): SelectionCollectionResult => {
73
+ // Create collector proxy using SelectionScope tree
74
+ const scope = new SelectionScope()
75
+ const collector = createCollectorProxy<unknown>(scope, entityType, schemaRegistry as SchemaRegistry<Record<string, object>> | null)
76
+
77
+ // Call collect function with the proxy to gather field access
78
+ const jsx = collectRef.current(collector)
79
+
80
+ // Analyze the returned JSX for component-level selections
81
+ let jsxSel: SelectionMeta
82
+ try {
83
+ jsxSel = collectSelection(jsx)
84
+ } catch {
85
+ // staticRender of nested components may crash during collection
86
+ jsxSel = { fields: new Map() }
87
+ }
88
+
89
+ // Convert scope to SelectionMeta and merge with JSX selection
90
+ const selection = scope.toSelectionMeta()
91
+ mergeSelections(selection, jsxSel)
92
+
93
+ // Debug output can be enabled via the debug prop on BindxProvider
94
+ if (bindxContext?.debug) {
95
+ console.log('[Selection] Collected selection for', entityType, ':')
96
+ console.log(debugSelection(selection))
97
+ }
98
+
99
+ // Create a stable key from the selection to detect actual changes
100
+ const query = buildQueryFromSelection(selection)
101
+ const newQueryKey = queryKeyExtra
102
+ ? JSON.stringify({ query, ...queryKeyExtra })
103
+ : JSON.stringify(query)
104
+
105
+ // If the selection hasn't actually changed, return cached values
106
+ const cache = selectionCacheRef.current
107
+ if (cache && cache.queryKey === newQueryKey) {
108
+ return cache
109
+ }
110
+
111
+ // Update cache with new values
112
+ const newCache: SelectionCache = {
113
+ selection,
114
+ queryKey: newQueryKey,
115
+ }
116
+ selectionCacheRef.current = newCache
117
+
118
+ return newCache
119
+ }, [entityType, depsKey, schemaRegistry]) // Only depend on entity identity, deps key, and schema
120
+
121
+ return result
122
+ }
@@ -0,0 +1,51 @@
1
+ import { useCallback, useRef, useSyncExternalStore } from 'react'
2
+
3
+ /**
4
+ * Options for useStoreSubscription hook.
5
+ */
6
+ export interface UseStoreSubscriptionOptions<T> {
7
+ /** Function to subscribe to store changes */
8
+ subscribe: (callback: () => void) => () => void
9
+ /** Function to get current snapshot */
10
+ getSnapshot: () => T
11
+ /** Optional equality function for cache comparison (defaults to ===) */
12
+ isEqual?: (a: T, b: T) => boolean
13
+ }
14
+
15
+ /**
16
+ * Generic hook for subscribing to store changes with useSyncExternalStore.
17
+ * Provides snapshot caching for referential stability.
18
+ *
19
+ * @internal This hook is for internal use only.
20
+ */
21
+ export function useStoreSubscription<T>(
22
+ options: UseStoreSubscriptionOptions<T>,
23
+ ): T {
24
+ const { subscribe, getSnapshot, isEqual = defaultIsEqual } = options
25
+
26
+ // Cache for snapshot to ensure referential stability
27
+ const cacheRef = useRef<{ value: T } | null>(null)
28
+
29
+ // Stable getSnapshot callback with caching
30
+ const getSnapshotCached = useCallback((): T => {
31
+ const newValue = getSnapshot()
32
+
33
+ // Return cached value if equal
34
+ if (cacheRef.current !== null && isEqual(cacheRef.current.value, newValue)) {
35
+ return cacheRef.current.value
36
+ }
37
+
38
+ // Update cache and return new value
39
+ cacheRef.current = { value: newValue }
40
+ return newValue
41
+ }, [getSnapshot, isEqual])
42
+
43
+ return useSyncExternalStore(subscribe, getSnapshotCached, getSnapshotCached)
44
+ }
45
+
46
+ /**
47
+ * Default equality check using strict equality.
48
+ */
49
+ function defaultIsEqual<T>(a: T, b: T): boolean {
50
+ return a === b
51
+ }
@@ -0,0 +1,83 @@
1
+ import { useCallback, useSyncExternalStore } from 'react'
2
+ import type { UndoState } from '@contember/bindx'
3
+ import { useBindxContext } from './BackendAdapterContext.js'
4
+
5
+ /**
6
+ * Result type for useUndo hook.
7
+ */
8
+ export interface UndoHookResult {
9
+ /** Whether undo is available */
10
+ canUndo: boolean
11
+ /** Whether redo is available */
12
+ canRedo: boolean
13
+ /** Whether undo/redo is currently blocked (e.g., during persist) */
14
+ isBlocked: boolean
15
+ /** Number of entries in undo stack */
16
+ undoCount: number
17
+ /** Number of entries in redo stack */
18
+ redoCount: number
19
+ /** Undo the last action or group */
20
+ undo: () => void
21
+ /** Redo the last undone action or group */
22
+ redo: () => void
23
+ /** Begin a manual group - all actions until endGroup are grouped as one undo entry */
24
+ beginGroup: (label?: string) => string
25
+ /** End a manual group */
26
+ endGroup: (groupId: string) => void
27
+ /** Clear all undo/redo history */
28
+ clear: () => void
29
+ }
30
+
31
+ /**
32
+ * Hook to access undo/redo functionality.
33
+ * Must be used within a BindxProvider with enableUndo={true}.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * function Editor() {
38
+ * const { canUndo, canRedo, undo, redo } = useUndo()
39
+ *
40
+ * return (
41
+ * <div>
42
+ * <button onClick={undo} disabled={!canUndo}>Undo</button>
43
+ * <button onClick={redo} disabled={!canRedo}>Redo</button>
44
+ * </div>
45
+ * )
46
+ * }
47
+ * ```
48
+ */
49
+ export function useUndo(): UndoHookResult {
50
+ const { undoManager } = useBindxContext()
51
+
52
+ if (!undoManager) {
53
+ throw new Error(
54
+ 'useUndo: Undo is not enabled. Set enableUndo={true} on BindxProvider.',
55
+ )
56
+ }
57
+
58
+ // Subscribe to undo manager state changes
59
+ const state = useSyncExternalStore(
60
+ useCallback((callback) => undoManager.subscribe(callback), [undoManager]),
61
+ useCallback(() => undoManager.getState(), [undoManager]),
62
+ useCallback(() => undoManager.getState(), [undoManager]),
63
+ )
64
+
65
+ const undo = useCallback(() => undoManager.undo(), [undoManager])
66
+ const redo = useCallback(() => undoManager.redo(), [undoManager])
67
+ const beginGroup = useCallback((label?: string) => undoManager.beginGroup(label), [undoManager])
68
+ const endGroup = useCallback((id: string) => undoManager.endGroup(id), [undoManager])
69
+ const clear = useCallback(() => undoManager.clear(), [undoManager])
70
+
71
+ return {
72
+ canUndo: state.canUndo,
73
+ canRedo: state.canRedo,
74
+ isBlocked: state.isBlocked,
75
+ undoCount: state.undoCount,
76
+ redoCount: state.redoCount,
77
+ undo,
78
+ redo,
79
+ beginGroup,
80
+ endGroup,
81
+ clear,
82
+ }
83
+ }