@contember/bindx-react 0.1.43 → 0.1.45
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.
- package/dist/jsx/collectorProxy.d.ts +1 -1
- package/dist/jsx/collectorProxy.d.ts.map +1 -1
- package/dist/jsx/components/Entity.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/hooks/useEntity.ts +6 -7
- package/src/hooks/useEntityList.ts +41 -12
- package/src/jsx/collectorProxy.ts +20 -6
- package/src/jsx/components/Entity.tsx +76 -15
|
@@ -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,
|
|
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,
|
|
1
|
+
{"version":3,"file":"Entity.d.ts","sourceRoot":"","sources":["../../../src/jsx/components/Entity.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAkG,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AAKhJ,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;AAmT9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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.
|
|
3
|
+
"version": "0.1.45",
|
|
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.
|
|
19
|
-
"@contember/bindx-client": "0.1.
|
|
18
|
+
"@contember/bindx": "0.1.45",
|
|
19
|
+
"@contember/bindx-client": "0.1.45",
|
|
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"
|
package/src/hooks/useEntity.ts
CHANGED
|
@@ -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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
179
|
-
const
|
|
180
|
-
const selectionRef = useRef<{ meta: SelectionMeta;
|
|
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 (
|
|
183
|
-
|
|
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 =
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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: () => {},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { memo, useCallback, useEffect, useMemo, useRef, useSyncExternalStore, type ReactElement } from 'react'
|
|
1
|
+
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'
|
|
2
2
|
import { useBindxContext, useSchemaRegistry } from '../../hooks/BackendAdapterContext.js'
|
|
3
3
|
import { annotateElement } from '../devAnnotations.js'
|
|
4
4
|
import { useEntity } from '../../hooks/useEntity.js'
|
|
@@ -163,27 +163,83 @@ interface CreateModeSnapshot {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
|
-
* Internal component for create mode (creating a new entity)
|
|
166
|
+
* Internal component for create mode (creating a new entity).
|
|
167
|
+
*
|
|
168
|
+
* The draft entity is minted in a layout effect, NEVER during render. A
|
|
169
|
+
* render-phase store mutation is unsafe two ways:
|
|
170
|
+
* - it leaks the draft whenever the render is discarded — StrictMode
|
|
171
|
+
* double-invoke, an aborted concurrent render, a thrown render, Suspense —
|
|
172
|
+
* because no effect cleanup runs for a render that never commits;
|
|
173
|
+
* - it synchronously notifies the store's global subscribers, force-updating an
|
|
174
|
+
* already-mounted aggregate subscriber (e.g. a `usePersist` consumer)
|
|
175
|
+
* mid-render. React rejects that ("Cannot update a component while rendering a
|
|
176
|
+
* different component") and the create form renders blank.
|
|
177
|
+
* A layout effect runs before paint, so the draft exists before the user sees
|
|
178
|
+
* anything and its creation notifies subscribers normally (no stale dirty state).
|
|
167
179
|
*/
|
|
168
180
|
function EntityCreateMode({
|
|
169
181
|
entityType,
|
|
170
182
|
children,
|
|
171
|
-
error: errorFallback,
|
|
172
183
|
onPersisted,
|
|
173
184
|
}: EntityCreateModeProps): ReactElement {
|
|
174
|
-
const { store
|
|
175
|
-
const schemaRegistry = useSchemaRegistry()
|
|
185
|
+
const { store } = useBindxContext()
|
|
176
186
|
const tempIdRef = useRef<string | null>(null)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (tempIdRef.current) {
|
|
181
|
-
|
|
187
|
+
const [tempId, setTempId] = useState<string | null>(null)
|
|
188
|
+
|
|
189
|
+
useLayoutEffect(() => {
|
|
190
|
+
if (!tempIdRef.current) {
|
|
191
|
+
tempIdRef.current = store.createEntity(entityType)
|
|
192
|
+
setTempId(tempIdRef.current)
|
|
193
|
+
} else if (!store.getPersistedId(entityType, tempIdRef.current) && !store.hasEntity(entityType, tempIdRef.current)) {
|
|
194
|
+
// Re-seed under the SAME temp id if a previous cleanup removed it (a React
|
|
195
|
+
// StrictMode mount→cleanup→mount cycle), so the form stays bound across it.
|
|
196
|
+
store.createEntity(entityType, { id: tempIdRef.current })
|
|
197
|
+
}
|
|
198
|
+
return () => {
|
|
199
|
+
const id = tempIdRef.current
|
|
200
|
+
// On unmount, discard a never-persisted draft: drop its root registration
|
|
201
|
+
// and let the reachability-aware sweep reclaim it ONLY if nothing else
|
|
202
|
+
// references it — a draft connected into another live parent stays reachable
|
|
203
|
+
// and survives (the diamond / shared-create case). A persisted entity is
|
|
204
|
+
// left untouched.
|
|
205
|
+
if (id && !store.getPersistedId(entityType, id)) {
|
|
206
|
+
store.unregisterRootEntity(entityType, id)
|
|
207
|
+
store.sweepUnreachableCreated()
|
|
208
|
+
}
|
|
182
209
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
210
|
+
}, [store, entityType])
|
|
211
|
+
|
|
212
|
+
// First render, before the layout effect mints the draft. The draft is created
|
|
213
|
+
// before paint, so this empty frame is never visible.
|
|
214
|
+
if (tempId === null) {
|
|
215
|
+
return <></>
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<EntityCreateModeInner entityType={entityType} tempId={tempId} onPersisted={onPersisted}>
|
|
220
|
+
{children}
|
|
221
|
+
</EntityCreateModeInner>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Renders the create-mode draft once it exists. Split out of EntityCreateMode so
|
|
227
|
+
* its store subscriptions and selection collection always run against a real
|
|
228
|
+
* tempId (never null) — the hooks stay unconditional.
|
|
229
|
+
*/
|
|
230
|
+
function EntityCreateModeInner({
|
|
231
|
+
entityType,
|
|
232
|
+
tempId,
|
|
233
|
+
children,
|
|
234
|
+
onPersisted,
|
|
235
|
+
}: {
|
|
236
|
+
entityType: string
|
|
237
|
+
tempId: string
|
|
238
|
+
children: (entity: EntityAccessor<unknown>) => React.ReactNode
|
|
239
|
+
onPersisted?: (id: string) => void
|
|
240
|
+
}): ReactElement {
|
|
241
|
+
const { store, dispatcher } = useBindxContext()
|
|
242
|
+
const schemaRegistry = useSchemaRegistry()
|
|
187
243
|
|
|
188
244
|
// Subscribe to store changes for this entity
|
|
189
245
|
const subscribe = useCallback(
|
|
@@ -298,7 +354,12 @@ function EntityHandleRenderer({
|
|
|
298
354
|
[rawHandle],
|
|
299
355
|
)
|
|
300
356
|
|
|
301
|
-
|
|
357
|
+
// `version` is a useMemo dep, so `rawHandle` is recreated on every entity data change (to give
|
|
358
|
+
// memoized children a fresh reference). Superseded handles need no cleanup: EntityHandle and its
|
|
359
|
+
// child Field/HasMany handles are stateless live views that own no resources (see BaseHandle),
|
|
360
|
+
// so they are reclaimed by GC once unreferenced — and a consumer that still holds an accessor
|
|
361
|
+
// from an earlier render (e.g. the Slate block editor dispatching `setValue` from an async
|
|
362
|
+
// onChange after the version bumped) can keep using it.
|
|
302
363
|
|
|
303
364
|
const result = children(handle as EntityAccessor<unknown>)
|
|
304
365
|
return <>{annotateElement(result, { 'data-entity': entityType, 'data-entity-id': entityId })}</>
|