@contember/bindx-react 0.1.42 → 0.1.44

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.
@@ -10,5 +10,5 @@ import { type EntityAccessor } from './types.js';
10
10
  * @param entityName - The entity type name (e.g., 'Task'), used for schema lookups
11
11
  * @param schemaRegistry - The schema registry for field type information
12
12
  */
13
- export declare function createCollectorProxy<T>(scope: SelectionScope, entityName?: string | null, schemaRegistry?: SchemaRegistry<Record<string, object>> | null): EntityAccessor<T>;
13
+ export declare function createCollectorProxy<T>(scope: SelectionScope, entityName?: string | null, schemaRegistry?: SchemaRegistry<Record<string, object>> | null, parentPath?: readonly string[]): EntityAccessor<T>;
14
14
  //# sourceMappingURL=collectorProxy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"collectorProxy.d.ts","sourceRoot":"","sources":["../../src/jsx/collectorProxy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,EAAE,cAAc,EAAyB,MAAM,kBAAkB,CAAA;AACxE,OAAO,EACN,KAAK,cAAc,EAUnB,MAAM,YAAY,CAAA;AASnB;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACrC,KAAK,EAAE,cAAc,EACrB,UAAU,GAAE,MAAM,GAAG,IAAW,EAChC,cAAc,GAAE,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,IAAW,GAClE,cAAc,CAAC,CAAC,CAAC,CA2CnB"}
1
+ {"version":3,"file":"collectorProxy.d.ts","sourceRoot":"","sources":["../../src/jsx/collectorProxy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,EAAE,cAAc,EAAyB,MAAM,kBAAkB,CAAA;AACxE,OAAO,EACN,KAAK,cAAc,EAUnB,MAAM,YAAY,CAAA;AASnB;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACrC,KAAK,EAAE,cAAc,EACrB,UAAU,GAAE,MAAM,GAAG,IAAW,EAChC,cAAc,GAAE,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,IAAW,EACpE,UAAU,GAAE,SAAS,MAAM,EAAO,GAChC,cAAc,CAAC,CAAC,CAAC,CA2CnB"}
@@ -1 +1 @@
1
- {"version":3,"file":"Entity.d.ts","sourceRoot":"","sources":["../../../src/jsx/components/Entity.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAuE,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AAKrH,OAAO,KAAK,EAAkB,SAAS,EAAiB,MAAM,aAAa,CAAA;AAC3E,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAiB,KAAK,UAAU,EAAgF,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAI1M;;;GAGG;AACH,UAAU,eAAe,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAChE,kCAAkC;IAClC,MAAM,EAAE,SAAS,CAAC,QAAQ,CAAC,CAAA;IAC3B,+EAA+E;IAC/E,QAAQ,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,KAAK,KAAK,CAAC,SAAS,CAAA;IACxE,qBAAqB;IACrB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,KAAK,CAAC,SAAS,CAAA;CAC9C;AAED;;GAEG;AACH,UAAU,aAAa,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAE,SAAQ,eAAe,CAAC,QAAQ,CAAC;IACjG,sFAAsF;IACtF,EAAE,EAAE,iBAAiB,CAAA;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,KAAK,CAAA;IACd,WAAW,CAAC,EAAE,KAAK,CAAA;IACnB,uBAAuB;IACvB,OAAO,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IACzB,yBAAyB;IACzB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAED;;GAEG;AACH,UAAU,iBAAiB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAE,SAAQ,eAAe,CAAC,QAAQ,CAAC;IACrG,EAAE,CAAC,EAAE,KAAK,CAAA;IACV,8DAA8D;IAC9D,MAAM,EAAE,IAAI,CAAA;IACZ,wEAAwE;IACxE,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,OAAO,CAAC,EAAE,KAAK,CAAA;IACf,QAAQ,CAAC,EAAE,KAAK,CAAA;CAChB;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,IACrF,aAAa,CAAC,QAAQ,CAAC,GACvB,iBAAiB,CAAC,QAAQ,CAAC,CAAA;AAsP9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,iBAAS,UAAU,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC1D,KAAK,EAAE,WAAW,CAAC,QAAQ,CAAC,GAC1B,YAAY,GAAG,IAAI,CA2BrB;AAGD,eAAO,MAAM,MAAM,EAAkC,OAAO,UAAU,CAAA"}
1
+ {"version":3,"file":"Entity.d.ts","sourceRoot":"","sources":["../../../src/jsx/components/Entity.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAuE,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AAKrH,OAAO,KAAK,EAAkB,SAAS,EAAiB,MAAM,aAAa,CAAA;AAC3E,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAiB,KAAK,UAAU,EAAgF,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAI1M;;;GAGG;AACH,UAAU,eAAe,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAChE,kCAAkC;IAClC,MAAM,EAAE,SAAS,CAAC,QAAQ,CAAC,CAAA;IAC3B,+EAA+E;IAC/E,QAAQ,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,KAAK,KAAK,CAAC,SAAS,CAAA;IACxE,qBAAqB;IACrB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,KAAK,CAAC,SAAS,CAAA;CAC9C;AAED;;GAEG;AACH,UAAU,aAAa,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAE,SAAQ,eAAe,CAAC,QAAQ,CAAC;IACjG,sFAAsF;IACtF,EAAE,EAAE,iBAAiB,CAAA;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,KAAK,CAAA;IACd,WAAW,CAAC,EAAE,KAAK,CAAA;IACnB,uBAAuB;IACvB,OAAO,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IACzB,yBAAyB;IACzB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAED;;GAEG;AACH,UAAU,iBAAiB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAE,SAAQ,eAAe,CAAC,QAAQ,CAAC;IACrG,EAAE,CAAC,EAAE,KAAK,CAAA;IACV,8DAA8D;IAC9D,MAAM,EAAE,IAAI,CAAA;IACZ,wEAAwE;IACxE,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,OAAO,CAAC,EAAE,KAAK,CAAA;IACf,QAAQ,CAAC,EAAE,KAAK,CAAA;CAChB;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,IACrF,aAAa,CAAC,QAAQ,CAAC,GACvB,iBAAiB,CAAC,QAAQ,CAAC,CAAA;AAiR9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,iBAAS,UAAU,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC1D,KAAK,EAAE,WAAW,CAAC,QAAQ,CAAC,GAC1B,YAAY,GAAG,IAAI,CA2BrB;AAGD,eAAO,MAAM,MAAM,EAAkC,OAAO,UAAU,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contember/bindx-react",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "Type-safe React data binding framework",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -15,8 +15,8 @@
15
15
  "react": ">=18.0.0"
16
16
  },
17
17
  "dependencies": {
18
- "@contember/bindx": "0.1.42",
19
- "@contember/bindx-client": "0.1.42",
18
+ "@contember/bindx": "0.1.44",
19
+ "@contember/bindx-client": "0.1.44",
20
20
  "@contember/graphql-client": "^2.1.0-beta.1",
21
21
  "@radix-ui/primitive": "^1.1.3",
22
22
  "@radix-ui/react-slot": "^1.2.4"
@@ -324,8 +324,8 @@ export function useEntity(
324
324
 
325
325
  // --- EntityHandle ---
326
326
  const rawHandle = useMemo(
327
- () => EntityHandle.createRaw(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>),
328
- [id, entityType, store, dispatcher, schemaRegistry, snapshot],
327
+ () => EntityHandle.createRaw(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>, undefined, selectionMeta),
328
+ [id, entityType, store, dispatcher, schemaRegistry, snapshot, selectionMeta],
329
329
  )
330
330
 
331
331
  const handle = useMemo(
@@ -333,11 +333,10 @@ export function useEntity(
333
333
  [rawHandle],
334
334
  )
335
335
 
336
- useEffect(() => {
337
- return () => {
338
- rawHandle.dispose()
339
- }
340
- }, [rawHandle])
336
+ // `snapshot` is a useMemo dep, so `rawHandle` is recreated on every entity data change (to give
337
+ // memoized children a fresh reference). Superseded handles need no cleanup: EntityHandle is a
338
+ // stateless live view that owns no resources (see BaseHandle), so it is reclaimed by GC once
339
+ // unreferenced and a handle a consumer still holds stays usable for late reads/writes.
341
340
 
342
341
  // --- Persist & reset callbacks ---
343
342
  const persist = useCallback(async () => {
@@ -174,18 +174,22 @@ export function useEntityList(
174
174
  const { store, dispatcher, batcher } = useBindxContext()
175
175
 
176
176
  // --- Selection resolution ---
177
+ // Both selection paths (definer 3rd-arg and pre-resolved options.selection)
178
+ // are stabilized identically: the raw meta is resolved every render, but its
179
+ // reference is only swapped when the serialized query content actually
180
+ // changes. This keeps `selectionMeta` identity stable across content-identical
181
+ // renders (e.g. DataGrid rebuilding a new-but-equal selection object), which
182
+ // in turn keeps the data-loading effect from refiring spuriously.
177
183
  // resolveSelectionMeta is a pure function; useRef is called unconditionally.
178
- const resolvedMeta = definer ? resolveSelectionMeta(definer) : null
179
- const definerQueryKey = resolvedMeta ? JSON.stringify(buildQueryFromSelection(resolvedMeta)) : null
180
- const selectionRef = useRef<{ meta: SelectionMeta; queryKey: string } | null>(null)
184
+ const rawMeta = definer ? resolveSelectionMeta(definer) : options.selection!
185
+ const selectionContentKey = JSON.stringify(buildQueryFromSelection(rawMeta))
186
+ const selectionRef = useRef<{ meta: SelectionMeta; contentKey: string } | null>(null)
181
187
 
182
- if (definerQueryKey && resolvedMeta) {
183
- if (!selectionRef.current || selectionRef.current.queryKey !== definerQueryKey) {
184
- selectionRef.current = { meta: resolvedMeta, queryKey: definerQueryKey }
185
- }
188
+ if (!selectionRef.current || selectionRef.current.contentKey !== selectionContentKey) {
189
+ selectionRef.current = { meta: rawMeta, contentKey: selectionContentKey }
186
190
  }
187
191
 
188
- const selectionMeta = definer ? selectionRef.current!.meta : options.selection!
192
+ const selectionMeta = selectionRef.current.meta
189
193
 
190
194
  // --- Stable options key ---
191
195
  const optionsKey = useMemo(
@@ -199,11 +203,14 @@ export function useEntityList(
199
203
  )
200
204
 
201
205
  // --- Effective query key for cache invalidation ---
206
+ // Derived from the stable selectionContentKey so it keeps identity while the
207
+ // query content is unchanged, even if the caller passes a fresh selection object.
202
208
  const effectiveQueryKey = useMemo(() => {
203
209
  if (options.queryKey) return options.queryKey
204
- const query = buildQueryFromSelection(selectionMeta)
205
- return JSON.stringify({ entityType, query })
206
- }, [options.queryKey, selectionMeta, entityType])
210
+ // selectionContentKey is already the serialized query; just namespace it
211
+ // by entity type (avoids double-encoding the JSON string).
212
+ return `${entityType}:${selectionContentKey}`
213
+ }, [options.queryKey, selectionContentKey, entityType])
207
214
 
208
215
  // --- List state tracking ---
209
216
  const listStateRef = useRef<{
@@ -283,6 +290,26 @@ export function useEntityList(
283
290
  [store],
284
291
  )
285
292
 
293
+ // Discard never-persisted drafts when the list unmounts. List-level creates are
294
+ // top-level roots (nothing else anchors them); dropping the root and running the
295
+ // reachability-aware sweep frees a truly-orphaned draft while preserving one that
296
+ // was meanwhile connected into another live parent (the diamond / shared-create
297
+ // case). A persisted draft (rekeyed to a server id) is left untouched.
298
+ useEffect(() => {
299
+ return () => {
300
+ let unrooted = false
301
+ for (const item of listStateRef.current.items) {
302
+ if (isTempId(item.id) && !store.getPersistedId(entityType, item.id)) {
303
+ store.unregisterRootEntity(entityType, item.id)
304
+ unrooted = true
305
+ }
306
+ }
307
+ if (unrooted) {
308
+ store.sweepUnreachableCreated()
309
+ }
310
+ }
311
+ }, [store, entityType])
312
+
286
313
  // --- Snapshot ---
287
314
  const getSnapshot = useCallback((): UseEntityListResult<any> => {
288
315
  const state = listStateRef.current
@@ -314,6 +341,8 @@ export function useEntityList(
314
341
  store,
315
342
  dispatcher,
316
343
  schemaRegistry as SchemaRegistry<Record<string, object>>,
344
+ undefined,
345
+ selectionMeta,
317
346
  ) as unknown as EntityAccessor<any>
318
347
  })
319
348
 
@@ -341,7 +370,7 @@ export function useEntityList(
341
370
  }
342
371
 
343
372
  return result
344
- }, [entityType, store, dispatcher, schemaRegistry, addItem, removeItem, moveItem])
373
+ }, [entityType, store, dispatcher, schemaRegistry, selectionMeta, addItem, removeItem, moveItem])
345
374
 
346
375
  const isEqual = useCallback(
347
376
  (a: UseEntityListResult<any>, b: UseEntityListResult<any>): boolean => {
@@ -33,13 +33,14 @@ export function createCollectorProxy<T>(
33
33
  scope: SelectionScope,
34
34
  entityName: string | null = null,
35
35
  schemaRegistry: SchemaRegistry<Record<string, object>> | null = null,
36
+ parentPath: readonly string[] = [],
36
37
  ): EntityAccessor<T> {
37
38
  const fieldsProxy = new Proxy({} as EntityFields<T>, {
38
39
  get(_, fieldName: string): FieldAccessor<unknown> | HasManyAccessor<unknown> | HasOneAccessor<unknown> {
39
40
  // Return a collector ref that works for all field types
40
41
  // The actual type (scalar/hasMany/hasOne) will be determined
41
42
  // by how it's used in components or by schema lookup
42
- return createCollectorFieldRef(scope, fieldName, entityName, schemaRegistry)
43
+ return createCollectorFieldRef(scope, fieldName, entityName, schemaRegistry, parentPath)
43
44
  },
44
45
  })
45
46
 
@@ -97,6 +98,7 @@ function createCollectorFieldRef(
97
98
  fieldName: string,
98
99
  entityName: string | null,
99
100
  schemaRegistry: SchemaRegistry<Record<string, object>> | null,
101
+ parentPath: readonly string[] = [],
100
102
  ): CollectorRef {
101
103
  // Look up field type from schema if available
102
104
  const fieldDef = entityName && schemaRegistry
@@ -132,11 +134,17 @@ function createCollectorFieldRef(
132
134
  return childScope
133
135
  }
134
136
 
137
+ // Absolute path from the root collector to this field. `fieldName` / `path`
138
+ // stay relative (last segment only) so selection/relation metadata is
139
+ // unaffected; `fullPath` carries the dotted chain for DataGrid columns.
140
+ const fullPath = [...parentPath, fieldName]
141
+
135
142
  const meta = {
136
143
  entityType: targetEntityName ?? '', // Collection phase - entity type from schema
137
144
  entityId: '', // Collection phase - no entity
138
145
  path: [fieldName],
139
146
  fieldName,
147
+ fullPath,
140
148
  isArray: isHasManyRelation,
141
149
  isRelation: isHasOneRelation || isHasManyRelation,
142
150
  enumName,
@@ -149,8 +157,10 @@ function createCollectorFieldRef(
149
157
  get(_, nestedFieldName: string) {
150
158
  // Get child scope (upgrades to relation)
151
159
  const scope = getChildScope()
152
- // Create nested field ref in the child scope, passing target entity info
153
- return createCollectorFieldRef(scope, nestedFieldName, targetEntityName, schemaRegistry)
160
+ // Create nested field ref in the child scope, passing target entity info.
161
+ // Thread the current field's fullPath so e.g. `it.author.name` carries
162
+ // the dotted absolute path `['author', 'name']`.
163
+ return createCollectorFieldRef(scope, nestedFieldName, targetEntityName, schemaRegistry, fullPath)
154
164
  },
155
165
  })
156
166
 
@@ -158,7 +168,9 @@ function createCollectorFieldRef(
158
168
  // Get child scope and mark as array relation
159
169
  const scope = getChildScope()
160
170
  parentScope.markAsArray(fieldName)
161
- // Call fn once with collector to gather nested selection, passing target entity info
171
+ // Call fn once with collector to gather nested selection, passing target entity info.
172
+ // Items of a has-many start a fresh path: dotted where/sort paths only
173
+ // make sense across has-one chains, not across collection items.
162
174
  fn(createCollectorProxy<unknown>(scope, targetEntityName, schemaRegistry), 0)
163
175
  return []
164
176
  }
@@ -211,9 +223,11 @@ function createCollectorFieldRef(
211
223
  $isDirty: false,
212
224
  $fields: hasOneFieldsProxy,
213
225
  get $entity(): EntityAccessor<unknown> {
214
- // Get child scope (upgrades to relation) and return proxy with scope
226
+ // Get child scope (upgrades to relation) and return proxy with scope.
227
+ // Thread fullPath for has-one so nested fields keep their dotted chain;
228
+ // has-many items start fresh (handled via getById/map).
215
229
  const scope = getChildScope()
216
- return createCollectorProxy<unknown>(scope, targetEntityName, schemaRegistry)
230
+ return createCollectorProxy<unknown>(scope, targetEntityName, schemaRegistry, isHasManyRelation ? [] : fullPath)
217
231
  },
218
232
  $delete: () => {},
219
233
  $remove: () => {},
@@ -185,6 +185,28 @@ function EntityCreateMode({
185
185
  return id
186
186
  }, [entityType, store])
187
187
 
188
+ // Lifecycle of the draft entity:
189
+ // - Re-seed it under the SAME temp id if a previous unmount's cleanup removed
190
+ // it. This keeps the form bound across a React StrictMode mount→cleanup→mount
191
+ // cycle (effects run twice in dev), where the memoized id survives but the
192
+ // snapshot would otherwise be gone. No-op on first mount and after the entity
193
+ // was persisted (rekeyed to a server id).
194
+ // - On unmount, discard a never-persisted draft. Drop its root registration and
195
+ // let the reachability-aware sweep reclaim it ONLY if nothing else references
196
+ // it — a draft connected into another live parent stays reachable and survives
197
+ // (the diamond / shared-create case). A persisted entity is left untouched.
198
+ useEffect(() => {
199
+ if (!store.getPersistedId(entityType, tempId) && !store.hasEntity(entityType, tempId)) {
200
+ store.createEntity(entityType, { id: tempId })
201
+ }
202
+ return () => {
203
+ if (!store.getPersistedId(entityType, tempId)) {
204
+ store.unregisterRootEntity(entityType, tempId)
205
+ store.sweepUnreachableCreated()
206
+ }
207
+ }
208
+ }, [store, entityType, tempId])
209
+
188
210
  // Subscribe to store changes for this entity
189
211
  const subscribe = useCallback(
190
212
  (callback: () => void) => store.subscribeToEntity(entityType, tempId, callback),
@@ -298,7 +320,12 @@ function EntityHandleRenderer({
298
320
  [rawHandle],
299
321
  )
300
322
 
301
- useEffect(() => () => { rawHandle.dispose() }, [rawHandle])
323
+ // `version` is a useMemo dep, so `rawHandle` is recreated on every entity data change (to give
324
+ // memoized children a fresh reference). Superseded handles need no cleanup: EntityHandle and its
325
+ // child Field/HasMany handles are stateless live views that own no resources (see BaseHandle),
326
+ // so they are reclaimed by GC once unreferenced — and a consumer that still holds an accessor
327
+ // from an earlier render (e.g. the Slate block editor dispatching `setValue` from an async
328
+ // onChange after the version bumped) can keep using it.
302
329
 
303
330
  const result = children(handle as EntityAccessor<unknown>)
304
331
  return <>{annotateElement(result, { 'data-entity': entityType, 'data-entity-id': entityId })}</>