@contember/bindx-react 0.1.45 → 0.1.47
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/hooks/ContemberBindxProvider.d.ts +2 -2
- package/dist/hooks/ContemberBindxProvider.d.ts.map +1 -1
- package/dist/hooks/ItemAccessorCache.d.ts +36 -0
- package/dist/hooks/ItemAccessorCache.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useEntityCount.d.ts.map +1 -1
- package/dist/hooks/useEntityList.d.ts.map +1 -1
- package/dist/hooks/useFields.d.ts +25 -0
- package/dist/hooks/useFields.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/jsx/analyzer.d.ts.map +1 -1
- package/dist/jsx/componentBuilder.d.ts +3 -1
- package/dist/jsx/componentBuilder.d.ts.map +1 -1
- package/dist/jsx/componentBuilder.types.d.ts +44 -8
- package/dist/jsx/componentBuilder.types.d.ts.map +1 -1
- package/dist/jsx/componentBuilderCompat.d.ts.map +1 -1
- package/dist/jsx/componentFactory.d.ts +1 -1
- package/dist/jsx/componentFactory.d.ts.map +1 -1
- package/dist/jsx/components/Field.d.ts.map +1 -1
- package/dist/jsx/components/If.d.ts.map +1 -1
- package/dist/jsx/components/Switch.d.ts.map +1 -1
- package/dist/jsx/standaloneCreateComponent.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/hooks/ContemberBindxProvider.tsx +2 -3
- package/src/hooks/ItemAccessorCache.ts +71 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useEntity.ts +48 -22
- package/src/hooks/useEntityCount.ts +0 -1
- package/src/hooks/useEntityList.ts +44 -16
- package/src/hooks/useFields.ts +118 -0
- package/src/index.ts +1 -1
- package/src/jsx/analyzer.ts +32 -7
- package/src/jsx/componentBuilder.ts +19 -2
- package/src/jsx/componentBuilder.types.ts +62 -8
- package/src/jsx/componentBuilderCompat.ts +0 -2
- package/src/jsx/componentFactory.ts +109 -42
- package/src/jsx/components/Attribute.tsx +2 -2
- package/src/jsx/components/Entity.tsx +12 -12
- package/src/jsx/components/Field.tsx +2 -4
- package/src/jsx/components/If.tsx +6 -0
- package/src/jsx/components/Switch.tsx +20 -9
- package/src/jsx/standaloneCreateComponent.ts +0 -2
package/src/hooks/useEntity.ts
CHANGED
|
@@ -323,9 +323,13 @@ export function useEntity(
|
|
|
323
323
|
}, [entityType, id, byKey, effectiveQueryKey, options.cache, batcher, store, dispatcher, selectionMeta])
|
|
324
324
|
|
|
325
325
|
// --- EntityHandle ---
|
|
326
|
+
// The handle keeps a stable identity across data changes — it is a stateless live view over the
|
|
327
|
+
// store (see BaseHandle), so `snapshot` is intentionally NOT a dependency here. The host already
|
|
328
|
+
// re-renders on data changes via useStoreSubscription above (keeping inline `accessor.value`
|
|
329
|
+
// reads fresh), and memoized children re-render through their own subscription (useField/useAccessor).
|
|
326
330
|
const rawHandle = useMemo(
|
|
327
331
|
() => EntityHandle.createRaw(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>, undefined, selectionMeta),
|
|
328
|
-
[id, entityType, store, dispatcher, schemaRegistry,
|
|
332
|
+
[id, entityType, store, dispatcher, schemaRegistry, selectionMeta],
|
|
329
333
|
)
|
|
330
334
|
|
|
331
335
|
const handle = useMemo(
|
|
@@ -333,11 +337,6 @@ export function useEntity(
|
|
|
333
337
|
[rawHandle],
|
|
334
338
|
)
|
|
335
339
|
|
|
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.
|
|
340
|
-
|
|
341
340
|
// --- Persist & reset callbacks ---
|
|
342
341
|
const persist = useCallback(async () => {
|
|
343
342
|
await batchPersister.persist(entityType, id)
|
|
@@ -347,6 +346,33 @@ export function useEntity(
|
|
|
347
346
|
rawHandle.reset()
|
|
348
347
|
}, [rawHandle])
|
|
349
348
|
|
|
349
|
+
// Keep the public ready proxy stable while serving fresh metadata from each render.
|
|
350
|
+
const readyMetaRef = useRef<ReadyResultMeta>({
|
|
351
|
+
$status: 'ready',
|
|
352
|
+
$isLoading: false,
|
|
353
|
+
$isRefetching: false,
|
|
354
|
+
$isError: false,
|
|
355
|
+
$isNotFound: false,
|
|
356
|
+
$error: null,
|
|
357
|
+
$persist: persist,
|
|
358
|
+
$reset: reset,
|
|
359
|
+
})
|
|
360
|
+
readyMetaRef.current = {
|
|
361
|
+
$status: 'ready',
|
|
362
|
+
$isLoading: false,
|
|
363
|
+
$isRefetching: isRefetching,
|
|
364
|
+
$isError: false,
|
|
365
|
+
$isNotFound: false,
|
|
366
|
+
$error: null,
|
|
367
|
+
$persist: persist,
|
|
368
|
+
$reset: reset,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const readyResult = useMemo(
|
|
372
|
+
() => createReadyResult(handle, readyMetaRef),
|
|
373
|
+
[handle],
|
|
374
|
+
)
|
|
375
|
+
|
|
350
376
|
// --- Build result ---
|
|
351
377
|
const result = useMemo((): UseEntityResult<object, object> => {
|
|
352
378
|
if (!loadState || loadState.status === 'loading' || (!snapshot && loadState.status === 'success')) {
|
|
@@ -362,8 +388,8 @@ export function useEntity(
|
|
|
362
388
|
}
|
|
363
389
|
|
|
364
390
|
// Ready — layer status metadata on top of EntityHandle proxy
|
|
365
|
-
return
|
|
366
|
-
}, [snapshot, loadState, isPersisting, id,
|
|
391
|
+
return readyResult
|
|
392
|
+
}, [snapshot, loadState, isPersisting, id, persist, reset, readyResult])
|
|
367
393
|
|
|
368
394
|
// Proxy-based result satisfies the full UseEntityResult<T> at runtime via field access delegation
|
|
369
395
|
return result as UseEntityResult<any, any>
|
|
@@ -373,32 +399,32 @@ export function useEntity(
|
|
|
373
399
|
// Result factories
|
|
374
400
|
// ============================================================================
|
|
375
401
|
|
|
402
|
+
interface ReadyResultMeta extends Record<string, unknown> {
|
|
403
|
+
readonly $status: 'ready'
|
|
404
|
+
readonly $isLoading: false
|
|
405
|
+
readonly $isRefetching: boolean
|
|
406
|
+
readonly $isError: false
|
|
407
|
+
readonly $isNotFound: false
|
|
408
|
+
readonly $error: null
|
|
409
|
+
$persist(): Promise<void>
|
|
410
|
+
$reset(): void
|
|
411
|
+
}
|
|
412
|
+
|
|
376
413
|
function createReadyResult(
|
|
377
414
|
handle: EntityAccessor<object, object>,
|
|
378
|
-
|
|
379
|
-
reset: () => void,
|
|
380
|
-
isRefetching: boolean,
|
|
415
|
+
metaRef: { readonly current: ReadyResultMeta },
|
|
381
416
|
): UseEntityResult<object, object> {
|
|
382
|
-
const meta: Record<string, unknown> = {
|
|
383
|
-
$status: 'ready' as const,
|
|
384
|
-
$isLoading: false as const,
|
|
385
|
-
$isRefetching: isRefetching,
|
|
386
|
-
$isError: false as const,
|
|
387
|
-
$isNotFound: false as const,
|
|
388
|
-
$error: null,
|
|
389
|
-
$persist: persist,
|
|
390
|
-
$reset: reset,
|
|
391
|
-
}
|
|
392
|
-
|
|
393
417
|
// Proxy merges status metadata onto EntityAccessor — satisfies ReadyEntityResult at runtime
|
|
394
418
|
return new Proxy(handle as object, {
|
|
395
419
|
get(target, prop, receiver) {
|
|
420
|
+
const meta = metaRef.current
|
|
396
421
|
if (typeof prop === 'string' && prop in meta) {
|
|
397
422
|
return meta[prop]
|
|
398
423
|
}
|
|
399
424
|
return Reflect.get(target, prop, receiver)
|
|
400
425
|
},
|
|
401
426
|
has(target, prop) {
|
|
427
|
+
const meta = metaRef.current
|
|
402
428
|
if (typeof prop === 'string' && prop in meta) {
|
|
403
429
|
return true
|
|
404
430
|
}
|
|
@@ -38,7 +38,6 @@ export interface UseEntityCountResult {
|
|
|
38
38
|
* recompute the count. Batched into the same request as any sibling list query.
|
|
39
39
|
*/
|
|
40
40
|
export function useEntityCount(
|
|
41
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
41
|
entity: EntityDef<any>,
|
|
43
42
|
options: UseEntityCountOptions = {},
|
|
44
43
|
): UseEntityCountResult {
|
|
@@ -3,6 +3,7 @@ import type { EntityDef, EntityAccessor, SelectionInput, SelectionMeta, FieldErr
|
|
|
3
3
|
import { EntityHandle, isTempId, resolveSelectionMeta, buildQueryFromSelection, refreshServerData, createLoadError } from '@contember/bindx'
|
|
4
4
|
import { useBindxContext, useSchemaRegistry } from './BackendAdapterContext.js'
|
|
5
5
|
import { useStoreSubscription } from './useStoreSubscription.js'
|
|
6
|
+
import { ItemAccessorCache } from './ItemAccessorCache.js'
|
|
6
7
|
|
|
7
8
|
// ============================================================================
|
|
8
9
|
// Options
|
|
@@ -162,11 +163,9 @@ export function useEntityList(
|
|
|
162
163
|
// Implementation
|
|
163
164
|
// ============================================================================
|
|
164
165
|
|
|
165
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
166
|
export function useEntityList(
|
|
167
167
|
entity: EntityDef<any>,
|
|
168
168
|
options: UseEntityListOptions,
|
|
169
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
170
169
|
definer?: SelectionInput<any, any>,
|
|
171
170
|
): UseEntityListResult<any> {
|
|
172
171
|
const schemaRegistry = useSchemaRegistry()
|
|
@@ -231,9 +230,38 @@ export function useEntityList(
|
|
|
231
230
|
storeVersion: number
|
|
232
231
|
status: string
|
|
233
232
|
isRefetching: boolean
|
|
233
|
+
accessorCache: ItemAccessorCache
|
|
234
234
|
result: UseEntityListResult<any>
|
|
235
235
|
} | null>(null)
|
|
236
236
|
|
|
237
|
+
// --- Item accessor cache ---
|
|
238
|
+
// Kept for the hook's lifetime so item identity survives a list snapshot rebuild. Dropped
|
|
239
|
+
// whenever a handle construction input changes — handles are built against `selectionMeta` and
|
|
240
|
+
// validate field access against it. A new cache also invalidates `listCacheRef`, so a widened
|
|
241
|
+
// selection never serves the previous result with its narrow accessors.
|
|
242
|
+
// Canonical id of a list item: a temp id follows its temp→persisted rekey. The list
|
|
243
|
+
// state keeps the id it was given, so every id comparison goes through this.
|
|
244
|
+
const resolveItemId = useCallback(
|
|
245
|
+
(id: string): string => store.resolveEntityId(entityType, id),
|
|
246
|
+
[store, entityType],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const itemAccessorCache = useMemo(
|
|
250
|
+
() => new ItemAccessorCache(
|
|
251
|
+
(id) => EntityHandle.createRaw<object>(
|
|
252
|
+
id,
|
|
253
|
+
entityType,
|
|
254
|
+
store,
|
|
255
|
+
dispatcher,
|
|
256
|
+
schemaRegistry as SchemaRegistry<Record<string, object>>,
|
|
257
|
+
undefined,
|
|
258
|
+
selectionMeta,
|
|
259
|
+
),
|
|
260
|
+
resolveItemId,
|
|
261
|
+
),
|
|
262
|
+
[entityType, store, dispatcher, schemaRegistry, selectionMeta, resolveItemId],
|
|
263
|
+
)
|
|
264
|
+
|
|
237
265
|
// --- Store subscription ---
|
|
238
266
|
const subscribe = useCallback(
|
|
239
267
|
(onStoreChange: () => void) => {
|
|
@@ -266,11 +294,12 @@ export function useEntityList(
|
|
|
266
294
|
} else {
|
|
267
295
|
store.scheduleForDeletion(entityType, key)
|
|
268
296
|
}
|
|
269
|
-
|
|
297
|
+
const removedId = resolveItemId(key)
|
|
298
|
+
listStateRef.current.items = listStateRef.current.items.filter(item => resolveItemId(item.id) !== removedId)
|
|
270
299
|
versionRef.current++
|
|
271
300
|
store.notify()
|
|
272
301
|
},
|
|
273
|
-
[entityType, store],
|
|
302
|
+
[entityType, store, resolveItemId],
|
|
274
303
|
)
|
|
275
304
|
|
|
276
305
|
const moveItem = useCallback(
|
|
@@ -322,7 +351,8 @@ export function useEntityList(
|
|
|
322
351
|
cache.version === version &&
|
|
323
352
|
cache.storeVersion === storeVersion &&
|
|
324
353
|
cache.status === state.status &&
|
|
325
|
-
cache.isRefetching === state.isRefetching
|
|
354
|
+
cache.isRefetching === state.isRefetching &&
|
|
355
|
+
cache.accessorCache === itemAccessorCache
|
|
326
356
|
) {
|
|
327
357
|
return cache.result
|
|
328
358
|
}
|
|
@@ -334,17 +364,14 @@ export function useEntityList(
|
|
|
334
364
|
} else if (state.status === 'error') {
|
|
335
365
|
result = createErrorListResult(state.error!)
|
|
336
366
|
} else {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
entityType,
|
|
341
|
-
store,
|
|
342
|
-
dispatcher,
|
|
343
|
-
schemaRegistry as SchemaRegistry<Record<string, object>>,
|
|
344
|
-
undefined,
|
|
345
|
-
selectionMeta,
|
|
346
|
-
) as unknown as EntityAccessor<any>
|
|
367
|
+
state.items = state.items.map(item => {
|
|
368
|
+
const id = resolveItemId(item.id)
|
|
369
|
+
return id === item.id ? item : { id, data: item.data }
|
|
347
370
|
})
|
|
371
|
+
// The array itself is deliberately NOT identity-stable: consumers that only re-render
|
|
372
|
+
// through a parent (the DataGrid render chain) rely on a fresh array per store bump.
|
|
373
|
+
// Per-item accessor identity is the stable part — see ItemAccessorCache.
|
|
374
|
+
const items = itemAccessorCache.build(state.items)
|
|
348
375
|
|
|
349
376
|
result = {
|
|
350
377
|
$status: 'ready',
|
|
@@ -366,11 +393,12 @@ export function useEntityList(
|
|
|
366
393
|
storeVersion,
|
|
367
394
|
status: state.status,
|
|
368
395
|
isRefetching: state.isRefetching,
|
|
396
|
+
accessorCache: itemAccessorCache,
|
|
369
397
|
result,
|
|
370
398
|
}
|
|
371
399
|
|
|
372
400
|
return result
|
|
373
|
-
}, [
|
|
401
|
+
}, [store, itemAccessorCache, addItem, removeItem, moveItem, resolveItemId])
|
|
374
402
|
|
|
375
403
|
const isEqual = useCallback(
|
|
376
404
|
(a: UseEntityListResult<any>, b: UseEntityListResult<any>): boolean => {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useCallback, useRef, useSyncExternalStore } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
FIELD_REF_META,
|
|
4
|
+
type FieldAccessor,
|
|
5
|
+
type FieldRef,
|
|
6
|
+
type FieldRefMeta,
|
|
7
|
+
} from '@contember/bindx'
|
|
8
|
+
import { useSnapshotStore } from './BackendAdapterContext.js'
|
|
9
|
+
|
|
10
|
+
/** Entity to watch, derived from a ref's field metadata. */
|
|
11
|
+
interface SubscriptionTarget {
|
|
12
|
+
readonly entityType: string
|
|
13
|
+
readonly entityId: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasFieldRefMeta(value: unknown): value is { readonly [FIELD_REF_META]: FieldRefMeta } {
|
|
17
|
+
return typeof value === 'object' && value !== null && FIELD_REF_META in value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Deduplicates the entities behind `refs`; entries without field metadata are ignored. */
|
|
21
|
+
function collectTargets(refs: ReadonlyArray<unknown>): SubscriptionTarget[] {
|
|
22
|
+
const targets: SubscriptionTarget[] = []
|
|
23
|
+
const seen = new Map<string, Set<string>>()
|
|
24
|
+
|
|
25
|
+
for (const ref of refs) {
|
|
26
|
+
if (!hasFieldRefMeta(ref)) continue
|
|
27
|
+
const meta = ref[FIELD_REF_META]
|
|
28
|
+
if (!meta) continue
|
|
29
|
+
let ids = seen.get(meta.entityType)
|
|
30
|
+
if (!ids) {
|
|
31
|
+
ids = new Set()
|
|
32
|
+
seen.set(meta.entityType, ids)
|
|
33
|
+
}
|
|
34
|
+
if (ids.has(meta.entityId)) continue
|
|
35
|
+
ids.add(meta.entityId)
|
|
36
|
+
targets.push({ entityType: meta.entityType, entityId: meta.entityId })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
targets.sort((left, right) => {
|
|
40
|
+
if (left.entityType < right.entityType) return -1
|
|
41
|
+
if (left.entityType > right.entityType) return 1
|
|
42
|
+
if (left.entityId < right.entityId) return -1
|
|
43
|
+
if (left.entityId > right.entityId) return 1
|
|
44
|
+
return 0
|
|
45
|
+
})
|
|
46
|
+
return targets
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type FieldAccessorFor<TRef> = TRef extends FieldRef<infer TValue>
|
|
50
|
+
? FieldAccessor<TValue>
|
|
51
|
+
: TRef extends null
|
|
52
|
+
? null
|
|
53
|
+
: never
|
|
54
|
+
|
|
55
|
+
type FieldRefFor<TRef> = TRef extends FieldRef<infer TValue>
|
|
56
|
+
? FieldRef<TValue>
|
|
57
|
+
: TRef extends null
|
|
58
|
+
? null
|
|
59
|
+
: never
|
|
60
|
+
|
|
61
|
+
type FieldAccessorTuple<TRefs extends readonly unknown[]> = {
|
|
62
|
+
readonly [TIndex in keyof TRefs]: FieldAccessorFor<TRefs[TIndex]>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type FieldRefTuple<TRefs extends readonly unknown[]> = {
|
|
66
|
+
readonly [TIndex in keyof TRefs]: FieldRefFor<TRefs[TIndex]>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Internal subscription path for metadata-bearing refs of any kind. */
|
|
70
|
+
export function useRefSubscription(refs: readonly unknown[]): readonly unknown[] {
|
|
71
|
+
const store = useSnapshotStore()
|
|
72
|
+
const targets = collectTargets(refs)
|
|
73
|
+
const subscriptionKey = JSON.stringify(targets)
|
|
74
|
+
const stableTargetsRef = useRef({ subscriptionKey, targets })
|
|
75
|
+
if (stableTargetsRef.current.subscriptionKey !== subscriptionKey) {
|
|
76
|
+
stableTargetsRef.current = { subscriptionKey, targets }
|
|
77
|
+
}
|
|
78
|
+
const stableTargets = stableTargetsRef.current.targets
|
|
79
|
+
const hasTargets = stableTargets.length > 0
|
|
80
|
+
|
|
81
|
+
const subscribe = useCallback(
|
|
82
|
+
(callback: () => void): (() => void) => {
|
|
83
|
+
const unsubscribes = stableTargets.map(target =>
|
|
84
|
+
store.subscribeToEntity(target.entityType, target.entityId, callback),
|
|
85
|
+
)
|
|
86
|
+
return () => {
|
|
87
|
+
for (const unsubscribe of unsubscribes) unsubscribe()
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[store, stableTargets],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const getSnapshot = useCallback(
|
|
94
|
+
(): number => (hasTargets ? store.getVersion() : 0),
|
|
95
|
+
[store, hasTargets],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
99
|
+
return refs
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Subscribes to every entity behind `refs` with a single hook, so the hook count stays
|
|
104
|
+
* constant no matter how many refs are passed. Use it wherever the number of refs is
|
|
105
|
+
* driven by data or by children (`<Switch>` cases, condition DSL fields) — calling
|
|
106
|
+
* {@link useField} in a loop breaks the rules of hooks the moment the count changes.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* const accessors = useFields([article.title, article.publishedAt])
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function useFields<const TRefs extends readonly unknown[]>(
|
|
114
|
+
refs: TRefs & FieldRefTuple<TRefs>,
|
|
115
|
+
): FieldAccessorTuple<TRefs>
|
|
116
|
+
export function useFields(refs: ReadonlyArray<unknown>): unknown {
|
|
117
|
+
return useRefSubscription(refs)
|
|
118
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -99,7 +99,6 @@ export type {
|
|
|
99
99
|
// Undo types
|
|
100
100
|
UndoManagerConfig,
|
|
101
101
|
UndoState,
|
|
102
|
-
UndoEntry,
|
|
103
102
|
// Event types
|
|
104
103
|
BindxEvent,
|
|
105
104
|
FieldScopedEvent,
|
|
@@ -310,6 +309,7 @@ export {
|
|
|
310
309
|
// Ref → Accessor hooks
|
|
311
310
|
useAccessor,
|
|
312
311
|
useField,
|
|
312
|
+
useFields,
|
|
313
313
|
useHasMany,
|
|
314
314
|
useHasOne,
|
|
315
315
|
// Notifications
|
package/src/jsx/analyzer.ts
CHANGED
|
@@ -60,8 +60,15 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
|
|
|
60
60
|
return nestedSelection
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
// Get selection info from the component
|
|
64
|
-
|
|
63
|
+
// Get selection info from the component. A throw here must not silently
|
|
64
|
+
// drop the sibling components' selections — report it and continue.
|
|
65
|
+
let fieldSelection: ReturnType<SelectionProvider['getSelection']>
|
|
66
|
+
try {
|
|
67
|
+
fieldSelection = provider.getSelection(element.props, collectNested)
|
|
68
|
+
} catch (error) {
|
|
69
|
+
reportAnalysisError(component, error)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
65
72
|
|
|
66
73
|
// Add to selection
|
|
67
74
|
if (fieldSelection) {
|
|
@@ -85,11 +92,15 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
|
|
|
85
92
|
'staticRender' in component &&
|
|
86
93
|
typeof (component as { staticRender: unknown }).staticRender === 'function'
|
|
87
94
|
) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
try {
|
|
96
|
+
const staticJsx = (component as { staticRender: (props: Record<string, unknown>) => ReactNode }).staticRender(
|
|
97
|
+
element.props as Record<string, unknown>,
|
|
98
|
+
)
|
|
99
|
+
if (staticJsx) {
|
|
100
|
+
analyzeJsx(staticJsx, selection)
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
reportAnalysisError(component, error)
|
|
93
104
|
}
|
|
94
105
|
return
|
|
95
106
|
}
|
|
@@ -102,6 +113,20 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
|
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Reports a selection-analysis failure of a single component with attribution,
|
|
118
|
+
* so one broken component doesn't silently cost its siblings their selection.
|
|
119
|
+
*/
|
|
120
|
+
function reportAnalysisError(component: unknown, error: unknown): void {
|
|
121
|
+
const c = component as { displayName?: string; name?: string; type?: { displayName?: string; name?: string } }
|
|
122
|
+
const name = c.displayName ?? c.type?.displayName ?? (c.name || c.type?.name) ?? 'anonymous'
|
|
123
|
+
console.error(
|
|
124
|
+
`[bindx] Selection analysis of <${name}> failed — its fields were NOT added to the fetch plan. `
|
|
125
|
+
+ 'Fix the error below; fields it uses may be missing from queries until then.',
|
|
126
|
+
error,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
105
130
|
/**
|
|
106
131
|
* Collects all field selections from a JSX tree.
|
|
107
132
|
* Entry point for the collection phase.
|
|
@@ -49,6 +49,7 @@ export class ComponentBuilderImpl<
|
|
|
49
49
|
private readonly hasInterfacesMode: boolean = false,
|
|
50
50
|
private readonly conditionFn: ((props: Record<string, unknown>) => Condition) | null = null,
|
|
51
51
|
private readonly slotNames: readonly string[] = ['children'],
|
|
52
|
+
private readonly useFns: readonly ((props: Record<string, unknown>) => object)[] = [],
|
|
52
53
|
) {}
|
|
53
54
|
|
|
54
55
|
entity(
|
|
@@ -67,6 +68,7 @@ export class ComponentBuilderImpl<
|
|
|
67
68
|
this.hasInterfacesMode,
|
|
68
69
|
this.conditionFn,
|
|
69
70
|
this.slotNames,
|
|
71
|
+
this.useFns,
|
|
70
72
|
)
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -96,6 +98,7 @@ export class ComponentBuilderImpl<
|
|
|
96
98
|
true, // Enable interfaces mode for discovery of implicit interface props
|
|
97
99
|
this.conditionFn,
|
|
98
100
|
this.slotNames,
|
|
101
|
+
this.useFns,
|
|
99
102
|
)
|
|
100
103
|
}
|
|
101
104
|
|
|
@@ -108,6 +111,19 @@ export class ComponentBuilderImpl<
|
|
|
108
111
|
this.hasInterfacesMode,
|
|
109
112
|
this.conditionFn,
|
|
110
113
|
this.slotNames,
|
|
114
|
+
this.useFns,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
use(useFn: (props: Record<string, unknown>) => object): ComponentBuilderImpl<TState> {
|
|
119
|
+
return new ComponentBuilderImpl(
|
|
120
|
+
this.schemaRegistry,
|
|
121
|
+
this.entityConfigs,
|
|
122
|
+
this.roles,
|
|
123
|
+
this.hasInterfacesMode,
|
|
124
|
+
this.conditionFn,
|
|
125
|
+
this.slotNames,
|
|
126
|
+
[...this.useFns, useFn],
|
|
111
127
|
)
|
|
112
128
|
}
|
|
113
129
|
|
|
@@ -119,6 +135,7 @@ export class ComponentBuilderImpl<
|
|
|
119
135
|
this.hasInterfacesMode,
|
|
120
136
|
conditionFn,
|
|
121
137
|
this.slotNames,
|
|
138
|
+
this.useFns,
|
|
122
139
|
)
|
|
123
140
|
}
|
|
124
141
|
|
|
@@ -130,6 +147,7 @@ export class ComponentBuilderImpl<
|
|
|
130
147
|
this.hasInterfacesMode,
|
|
131
148
|
this.conditionFn,
|
|
132
149
|
names,
|
|
150
|
+
this.useFns,
|
|
133
151
|
)
|
|
134
152
|
}
|
|
135
153
|
|
|
@@ -142,6 +160,7 @@ export class ComponentBuilderImpl<
|
|
|
142
160
|
this.schemaRegistry,
|
|
143
161
|
this.conditionFn,
|
|
144
162
|
this.slotNames,
|
|
163
|
+
this.useFns,
|
|
145
164
|
)
|
|
146
165
|
}
|
|
147
166
|
}
|
|
@@ -159,13 +178,11 @@ export class ComponentBuilderImpl<
|
|
|
159
178
|
export function createComponentBuilder(
|
|
160
179
|
schemaRegistry: SchemaRegistry<Record<string, object>> | null,
|
|
161
180
|
roles: readonly string[] = [],
|
|
162
|
-
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
163
181
|
): ComponentBuilder<ComponentBuilderState<{}, object, typeof roles>> {
|
|
164
182
|
return new ComponentBuilderImpl(
|
|
165
183
|
schemaRegistry,
|
|
166
184
|
new Map(),
|
|
167
185
|
roles,
|
|
168
|
-
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
169
186
|
) as unknown as ComponentBuilder<ComponentBuilderState<{}, object, typeof roles>>
|
|
170
187
|
}
|
|
171
188
|
|
|
@@ -103,14 +103,15 @@ export type AnyEntityPropConfig = EntityPropConfig | InterfaceEntityPropConfig
|
|
|
103
103
|
* Grows as you chain builder methods.
|
|
104
104
|
*/
|
|
105
105
|
export interface ComponentBuilderState<
|
|
106
|
-
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
107
106
|
TEntityProps extends Record<string, AnyEntityPropConfig> = {},
|
|
108
107
|
TScalarProps extends object = object,
|
|
109
108
|
TRoles extends readonly string[] = readonly string[],
|
|
109
|
+
TUseProps extends object = {},
|
|
110
110
|
> {
|
|
111
111
|
readonly __entityProps: TEntityProps
|
|
112
112
|
readonly __scalarProps: TScalarProps
|
|
113
113
|
readonly __roles: TRoles
|
|
114
|
+
readonly __useProps: TUseProps
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
// ============================================================================
|
|
@@ -130,7 +131,8 @@ export type AddImplicitEntity<
|
|
|
130
131
|
readonly [K in TPropName]: ImplicitEntityConfig<TEntity>
|
|
131
132
|
},
|
|
132
133
|
TState['__scalarProps'],
|
|
133
|
-
TState['__roles']
|
|
134
|
+
TState['__roles'],
|
|
135
|
+
TState['__useProps']
|
|
134
136
|
>
|
|
135
137
|
|
|
136
138
|
/**
|
|
@@ -147,7 +149,8 @@ export type AddExplicitEntity<
|
|
|
147
149
|
readonly [K in TPropName]: ExplicitEntityConfig<TEntity, TSelected>
|
|
148
150
|
},
|
|
149
151
|
TState['__scalarProps'],
|
|
150
|
-
TState['__roles']
|
|
152
|
+
TState['__roles'],
|
|
153
|
+
TState['__useProps']
|
|
151
154
|
>
|
|
152
155
|
|
|
153
156
|
/**
|
|
@@ -162,7 +165,8 @@ export type AddImplicitInterfaceEntity<
|
|
|
162
165
|
readonly [K in TPropName]: ImplicitInterfaceEntityConfig<TInterface>
|
|
163
166
|
},
|
|
164
167
|
TState['__scalarProps'],
|
|
165
|
-
TState['__roles']
|
|
168
|
+
TState['__roles'],
|
|
169
|
+
TState['__useProps']
|
|
166
170
|
>
|
|
167
171
|
|
|
168
172
|
/**
|
|
@@ -177,7 +181,8 @@ export type AddExplicitInterfaceEntity<
|
|
|
177
181
|
readonly [K in TPropName]: ExplicitInterfaceEntityConfig<TInterface>
|
|
178
182
|
},
|
|
179
183
|
TState['__scalarProps'],
|
|
180
|
-
TState['__roles']
|
|
184
|
+
TState['__roles'],
|
|
185
|
+
TState['__useProps']
|
|
181
186
|
>
|
|
182
187
|
|
|
183
188
|
/**
|
|
@@ -192,7 +197,8 @@ export type AddInterfaces<
|
|
|
192
197
|
readonly [K in keyof TInterfaces]: InterfaceEntityPropConfig<TInterfaces[K]>
|
|
193
198
|
},
|
|
194
199
|
TState['__scalarProps'],
|
|
195
|
-
TState['__roles']
|
|
200
|
+
TState['__roles'],
|
|
201
|
+
TState['__useProps']
|
|
196
202
|
>
|
|
197
203
|
|
|
198
204
|
/**
|
|
@@ -214,7 +220,21 @@ export type SetScalarProps<
|
|
|
214
220
|
> = ComponentBuilderState<
|
|
215
221
|
TState['__entityProps'],
|
|
216
222
|
TNewScalarProps,
|
|
217
|
-
TState['__roles']
|
|
223
|
+
TState['__roles'],
|
|
224
|
+
TState['__useProps']
|
|
225
|
+
>
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Merge runtime-only values from .use() into builder state.
|
|
229
|
+
*/
|
|
230
|
+
export type AddUseProps<
|
|
231
|
+
TState extends ComponentBuilderState,
|
|
232
|
+
TUse extends object,
|
|
233
|
+
> = ComponentBuilderState<
|
|
234
|
+
TState['__entityProps'],
|
|
235
|
+
TState['__scalarProps'],
|
|
236
|
+
TState['__roles'],
|
|
237
|
+
TState['__useProps'] & TUse
|
|
218
238
|
>
|
|
219
239
|
|
|
220
240
|
// ============================================================================
|
|
@@ -273,9 +293,12 @@ export type BuildProps<TState extends ComponentBuilderState> =
|
|
|
273
293
|
|
|
274
294
|
/**
|
|
275
295
|
* Build complete render props type from builder state.
|
|
296
|
+
* Includes .use() values — they exist only inside the render function,
|
|
297
|
+
* never on the public props (BuildProps).
|
|
276
298
|
*/
|
|
277
299
|
export type BuildRenderProps<TState extends ComponentBuilderState> =
|
|
278
300
|
TState['__scalarProps'] &
|
|
301
|
+
TState['__useProps'] &
|
|
279
302
|
BuildRenderEntityProps<TState['__entityProps'], TState['__roles']>
|
|
280
303
|
|
|
281
304
|
/**
|
|
@@ -425,6 +448,38 @@ export interface ComponentBuilder<
|
|
|
425
448
|
*/
|
|
426
449
|
props<TNewScalarProps extends object>(): ComponentBuilder<SetScalarProps<TState, TNewScalarProps>>
|
|
427
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Provide runtime-only values to the render function — hooks are allowed here.
|
|
453
|
+
*
|
|
454
|
+
* The function runs during every React render (inside the component), so
|
|
455
|
+
* `useT()`, `useContext()`, `useMemo()` etc. all work. It is NEVER executed
|
|
456
|
+
* during static selection analysis — its outputs are replaced by inert
|
|
457
|
+
* stand-ins there, so the render body can use them freely without crashing
|
|
458
|
+
* the collection pass.
|
|
459
|
+
*
|
|
460
|
+
* This removes the need for thin wrapper components that only thread
|
|
461
|
+
* hook-derived values in as props (which also break selection discovery,
|
|
462
|
+
* because the JSX walk cannot see through plain components).
|
|
463
|
+
*
|
|
464
|
+
* Can be chained; later use() functions see the values of earlier ones.
|
|
465
|
+
*
|
|
466
|
+
* @example
|
|
467
|
+
* ```typescript
|
|
468
|
+
* createComponent()
|
|
469
|
+
* .entity('article', schema.Article)
|
|
470
|
+
* .use(() => ({ t: useTranslator() }))
|
|
471
|
+
* .render(({ article, t }) => (
|
|
472
|
+
* <div>
|
|
473
|
+
* <h2>{t('article.heading')}</h2>
|
|
474
|
+
* <Field field={article.title} />
|
|
475
|
+
* </div>
|
|
476
|
+
* ))
|
|
477
|
+
* ```
|
|
478
|
+
*/
|
|
479
|
+
use<TUse extends object>(
|
|
480
|
+
useFn: (props: BuildRenderProps<TState>) => TUse,
|
|
481
|
+
): ComponentBuilder<AddUseProps<TState, TUse>>
|
|
482
|
+
|
|
428
483
|
/**
|
|
429
484
|
* Add a condition that must be true for the component to render.
|
|
430
485
|
* If the condition is false, the component renders null.
|
|
@@ -492,7 +547,6 @@ export interface ComponentBuilder<
|
|
|
492
547
|
*/
|
|
493
548
|
export type InitialBuilderState<
|
|
494
549
|
TRoles extends readonly string[] = readonly string[],
|
|
495
|
-
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
496
550
|
> = ComponentBuilderState<{}, object, TRoles>
|
|
497
551
|
|
|
498
552
|
/**
|
|
@@ -13,7 +13,6 @@ import { BINDX_COMPONENT } from './types.js'
|
|
|
13
13
|
* @internal
|
|
14
14
|
*/
|
|
15
15
|
export function assignFragmentProperties(
|
|
16
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
16
|
component: ComponentType<any>,
|
|
18
17
|
selectionsMap: Map<string, SelectionPropMeta>,
|
|
19
18
|
): void {
|
|
@@ -28,7 +27,6 @@ export function assignFragmentProperties(
|
|
|
28
27
|
* @internal
|
|
29
28
|
*/
|
|
30
29
|
export function assignComponentMarkers(
|
|
31
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
30
|
component: ComponentType<any>,
|
|
33
31
|
selectionsMap: Map<string, SelectionPropMeta>,
|
|
34
32
|
): void {
|