@contember/bindx-react 0.1.46 → 0.1.48

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 (41) hide show
  1. package/dist/hooks/ItemAccessorCache.d.ts +36 -0
  2. package/dist/hooks/ItemAccessorCache.d.ts.map +1 -0
  3. package/dist/hooks/index.d.ts +1 -0
  4. package/dist/hooks/index.d.ts.map +1 -1
  5. package/dist/hooks/useEntityCount.d.ts.map +1 -1
  6. package/dist/hooks/useEntityList.d.ts.map +1 -1
  7. package/dist/hooks/useFields.d.ts +25 -0
  8. package/dist/hooks/useFields.d.ts.map +1 -0
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/jsx/analyzer.d.ts.map +1 -1
  12. package/dist/jsx/componentBuilder.d.ts +3 -1
  13. package/dist/jsx/componentBuilder.d.ts.map +1 -1
  14. package/dist/jsx/componentBuilder.types.d.ts +44 -8
  15. package/dist/jsx/componentBuilder.types.d.ts.map +1 -1
  16. package/dist/jsx/componentBuilderCompat.d.ts.map +1 -1
  17. package/dist/jsx/componentFactory.d.ts +1 -1
  18. package/dist/jsx/componentFactory.d.ts.map +1 -1
  19. package/dist/jsx/components/Field.d.ts.map +1 -1
  20. package/dist/jsx/components/If.d.ts.map +1 -1
  21. package/dist/jsx/components/Switch.d.ts.map +1 -1
  22. package/dist/jsx/standaloneCreateComponent.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/src/hooks/ItemAccessorCache.ts +71 -0
  25. package/src/hooks/index.ts +1 -0
  26. package/src/hooks/useEntity.ts +48 -22
  27. package/src/hooks/useEntityCount.ts +0 -1
  28. package/src/hooks/useEntityList.ts +44 -16
  29. package/src/hooks/useFields.ts +118 -0
  30. package/src/index.ts +1 -1
  31. package/src/jsx/analyzer.ts +32 -7
  32. package/src/jsx/componentBuilder.ts +19 -2
  33. package/src/jsx/componentBuilder.types.ts +62 -8
  34. package/src/jsx/componentBuilderCompat.ts +0 -2
  35. package/src/jsx/componentFactory.ts +109 -42
  36. package/src/jsx/components/Attribute.tsx +2 -2
  37. package/src/jsx/components/Entity.tsx +12 -12
  38. package/src/jsx/components/Field.tsx +2 -4
  39. package/src/jsx/components/If.tsx +6 -0
  40. package/src/jsx/components/Switch.tsx +20 -9
  41. package/src/jsx/standaloneCreateComponent.ts +0 -2
@@ -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, snapshot, selectionMeta],
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 createReadyResult(handle, persist, reset, isRefetching)
366
- }, [snapshot, loadState, isPersisting, id, handle, persist, reset, isRefetching])
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
- persist: () => Promise<void>,
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
- listStateRef.current.items = listStateRef.current.items.filter(item => item.id !== key)
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
- const items = state.items.map((item) => {
338
- return EntityHandle.create<object>(
339
- item.id,
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
- }, [entityType, store, dispatcher, schemaRegistry, selectionMeta, addItem, removeItem, moveItem])
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
@@ -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
- const fieldSelection = provider.getSelection(element.props, collectNested)
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
- const staticJsx = (component as { staticRender: (props: Record<string, unknown>) => ReactNode }).staticRender(
89
- element.props as Record<string, unknown>,
90
- )
91
- if (staticJsx) {
92
- analyzeJsx(staticJsx, selection)
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 {