@contember/bindx-react 0.1.46 → 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.
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
@@ -15,7 +15,6 @@ import type {
15
15
  SelectionMeta,
16
16
  SelectionBuilder,
17
17
  AnyBrand,
18
- EntityRef,
19
18
  SchemaDefinition,
20
19
  } from '@contember/bindx'
21
20
  import {
@@ -33,7 +32,7 @@ import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from './types.js'
33
32
  import { createCollectorProxy } from './proxy.js'
34
33
  import { collectSelection } from './analyzer.js'
35
34
  import { type Condition, evaluateCondition } from './conditions.js'
36
- import { useAccessor } from '../hooks/useAccessor.js'
35
+ import { useRefSubscription } from '../hooks/useFields.js'
37
36
 
38
37
  // ============================================================================
39
38
  // Symbols
@@ -70,17 +69,19 @@ export interface EntityConfig {
70
69
  // ============================================================================
71
70
 
72
71
  /**
73
- * Converts explicit entity ref props to accessors via useAccessor.
74
- * Called with a fixed list of prop names — hook count is stable across renders.
72
+ * Converts entity ref props to accessors while subscribing with one fixed hook.
75
73
  */
76
- function useRenderProps<TProps extends object>(props: TProps, explicitPropNames: string[]): TProps {
77
- const record = props as Record<string, unknown>
78
- const accessors: Record<string, unknown> = {}
79
- for (const name of explicitPropNames) {
80
- // eslint-disable-next-line react-hooks/rules-of-hooks -- stable iteration count (explicitPropNames is fixed at build time)
81
- accessors[name] = useAccessor(record[name] as EntityRef<object>)
82
- }
83
- return { ...props, ...accessors } as TProps
74
+ function readProperty(target: object, name: string): unknown {
75
+ return Reflect.get(target, name)
76
+ }
77
+
78
+ function useRenderProps<TProps extends object>(props: TProps, entityPropNames: string[]): TProps {
79
+ const refs = entityPropNames.map(name => readProperty(props, name))
80
+ const accessors = useRefSubscription(refs)
81
+ const accessorProps = Object.fromEntries(
82
+ entityPropNames.map((name, index) => [name, accessors[index]]),
83
+ )
84
+ return Object.assign({}, props, accessorProps)
84
85
  }
85
86
 
86
87
  // ============================================================================
@@ -101,8 +102,10 @@ export function buildComponent<TProps extends object>(
101
102
  schemaRegistry: SchemaRegistry<Record<string, object>> | null,
102
103
  conditionFn: ((props: TProps) => Condition) | null,
103
104
  slotNames: readonly string[],
105
+ useFns: readonly ((props: TProps) => object)[],
104
106
  ): unknown {
105
107
  const selectionsMap = new Map<string, SelectionPropMeta>()
108
+ const componentDisplayName = `BindxComponent(${[...entityConfigs.keys()].join(', ')})`
106
109
 
107
110
  // Generate unique brand for this component
108
111
  const componentBrand = new ComponentBrand(`component_${Math.random().toString(36).slice(2)}`)
@@ -121,34 +124,58 @@ export function buildComponent<TProps extends object>(
121
124
  }
122
125
  }
123
126
 
124
- // Collect explicit entity prop names (stable list for hooks)
125
- const explicitEntityPropNames = [...entityConfigs.entries()]
126
- .filter(([_, c]) => c.selector)
127
- .map(([name]) => name)
127
+ // Every entity prop is subscribed, selector or not: accessor identity is stable, so a
128
+ // memo()-wrapped component only learns about its entity through its own subscription.
129
+ // Interface props are appended during lazy collection, before the first runtime render.
130
+ const entityPropNames = [...entityConfigs.keys()]
128
131
 
129
132
  // 2. Implicit entities - collect lazily to avoid TDZ errors
130
133
  const implicitConfigs = [...entityConfigs.entries()].filter(([_, c]) => !c.selector)
131
- let implicitCollected = false
134
+ // Tri-state: 'collecting' terminates self-recursive components
135
+ let collectionState: 'idle' | 'collecting' | 'done' = 'idle'
132
136
 
137
+ // Runs only from the static-analysis surface (getSelection, $propName fragment
138
+ // getters) — never from render. Render bodies stay pure runtime code.
133
139
  function ensureImplicitCollected(): void {
134
140
  // In interfaces mode, we also need to collect even if no explicit implicit configs exist
135
141
  // (because interface props may be discovered dynamically)
136
142
  // Also collect if there's a conditionFn (it may access entity fields)
137
- if (implicitCollected || (implicitConfigs.length === 0 && !hasInterfacesMode && !conditionFn)) {
143
+ if (collectionState !== 'idle' || (implicitConfigs.length === 0 && !hasInterfacesMode && !conditionFn)) {
138
144
  return
139
145
  }
140
- implicitCollected = true
141
- collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn)
146
+ collectionState = 'collecting'
147
+ try {
148
+ collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn)
149
+ } catch (error) {
150
+ // Analysis is deterministic, so retrying is pointless — degrade loudly
151
+ // to the fields captured before the throw (scopes record eagerly).
152
+ console.error(
153
+ `[bindx] Implicit selection analysis of <${componentDisplayName}> failed — `
154
+ + 'only fields accessed before the error were collected; fields used after it may be missing from queries.',
155
+ error,
156
+ )
157
+ } finally {
158
+ collectionState = 'done'
159
+ // Subscribe the props collection discovered too, incl. a degraded partial pass.
160
+ for (const propName of selectionsMap.keys()) {
161
+ if (!entityPropNames.includes(propName)) entityPropNames.push(propName)
162
+ }
163
+ }
142
164
  }
143
165
 
144
166
  // 3. Create React component
145
167
  function ComponentImpl(props: TProps): ReactNode {
146
168
  ensureImplicitCollected()
147
169
 
148
- // Convert explicit entity refs to accessors (stable hook count — explicitEntityPropNames is fixed)
149
- const renderProps = explicitEntityPropNames.length > 0
150
- ? useRenderProps(props, explicitEntityPropNames)
151
- : props
170
+ // Subscribe every entity ref prop; an empty fixed list is a no-op.
171
+ let renderProps: TProps = useRenderProps(props, entityPropNames)
172
+
173
+ // Runtime-only values from .use() — hooks are allowed here; static analysis
174
+ // never executes these (their outputs are mocked in the collection pass).
175
+ for (const useFn of useFns) {
176
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- stable iteration count (useFns is fixed at build time)
177
+ renderProps = { ...renderProps, ...useFn(renderProps) }
178
+ }
152
179
 
153
180
  // Evaluate condition at runtime
154
181
  if (conditionFn) {
@@ -161,6 +188,8 @@ export function buildComponent<TProps extends object>(
161
188
  }
162
189
 
163
190
  const MemoizedComponent = memo(ComponentImpl)
191
+ // Names for selection-analysis error attribution; users can override
192
+ MemoizedComponent.displayName = componentDisplayName
164
193
 
165
194
  // 4. Attach metadata
166
195
  const comp = MemoizedComponent as unknown as Record<symbol | string, unknown>
@@ -262,6 +291,34 @@ function createExplicitPropMock(): unknown {
262
291
  })
263
292
  }
264
293
 
294
+ /**
295
+ * Creates a tolerant stand-in for scalar (non-entity) props during collection.
296
+ * Render bodies may call it (`t('key')`), read nested properties
297
+ * (`labels.heading`) or coerce it to a primitive — all are no-ops so the
298
+ * collection pass keeps capturing entity field accesses (see issue #57).
299
+ */
300
+ function createScalarPropMock(): unknown {
301
+ const mock: unknown = new Proxy(function () {}, {
302
+ get(_target, prop): unknown {
303
+ if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') {
304
+ return (): string => ''
305
+ }
306
+ if (prop === Symbol.iterator) {
307
+ return function* (): Generator<never> {}
308
+ }
309
+ // undefined keeps JSON.stringify from recursing via a callable toJSON
310
+ if (prop === 'toJSON') {
311
+ return undefined
312
+ }
313
+ return mock
314
+ },
315
+ apply(): unknown {
316
+ return mock
317
+ },
318
+ })
319
+ return mock
320
+ }
321
+
265
322
  /**
266
323
  * Collects selections from JSX for implicit entity props.
267
324
  *
@@ -327,18 +384,38 @@ function collectImplicitSelections<TProps extends object>(
327
384
  return createCollectorProxy(scope, null, schemaRegistry)
328
385
  }
329
386
 
330
- // Scalar prop - return undefined
331
- return undefined
387
+ // Scalar prop — tolerant stand-in so render bodies using it survive
388
+ return createScalarPropMock()
332
389
  },
333
390
  })
334
391
 
335
- // Execute conditionFn to capture field accesses from the condition
336
- if (conditionFn) {
337
- conditionFn(propsProxy)
392
+ // Create fragments for captured entities
393
+ const finalizeScopes = (): void => {
394
+ for (const [propName, scope] of propScopes) {
395
+ if (scope.hasFields()) {
396
+ const selection = scope.toSelectionMeta()
397
+
398
+ selectionsMap.set(propName, {
399
+ selection,
400
+ fragment: createFragment(selection, componentBrand, roles),
401
+ })
402
+ }
403
+ }
338
404
  }
339
405
 
340
- // Execute render to capture field accesses
341
- const jsx = renderFn(propsProxy)
406
+ let jsx: ReactNode
407
+ try {
408
+ // Execute conditionFn + render to capture field accesses
409
+ if (conditionFn) {
410
+ conditionFn(propsProxy)
411
+ }
412
+ jsx = renderFn(propsProxy)
413
+ } catch (error) {
414
+ // Scopes capture accesses eagerly, so a throw mid-render still leaves a
415
+ // usable partial selection — keep it, then let the caller report the error.
416
+ finalizeScopes()
417
+ throw error
418
+ }
342
419
 
343
420
  // Analyze JSX tree for component-level selections (handles nested createComponent)
344
421
  try {
@@ -349,17 +426,7 @@ function collectImplicitSelections<TProps extends object>(
349
426
  // Field accesses are still captured in the scope tree via proxy.
350
427
  }
351
428
 
352
- // Create fragments for captured entities
353
- for (const [propName, scope] of propScopes) {
354
- if (scope.hasFields()) {
355
- const selection = scope.toSelectionMeta()
356
-
357
- selectionsMap.set(propName, {
358
- selection,
359
- fragment: createFragment(selection, componentBrand, roles),
360
- })
361
- }
362
- }
429
+ finalizeScopes()
363
430
  }
364
431
 
365
432
  // ============================================================================
@@ -34,11 +34,11 @@ export interface AttributeProps<T> {
34
34
  * ```
35
35
  */
36
36
  function AttributeImpl<T>({ field, format, children }: AttributeProps<T>): ReactElement | null {
37
- if (field === undefined || field === null) {
37
+ const accessor = useField(field ?? null)
38
+ if (accessor === null) {
38
39
  return children
39
40
  }
40
41
 
41
- const accessor = useField(field)
42
42
  const extraProps = {
43
43
  ...format(accessor),
44
44
  ...(isDevAnnotationsEnabled() ? { 'data-field': field[FIELD_REF_META]?.fieldName } : {}),
@@ -319,9 +319,13 @@ interface EntityHandleRendererProps {
319
319
  * Shared component that creates an EntityHandle and renders children with it.
320
320
  * Used by both EntityByMode and EntityCreateMode.
321
321
  *
322
- * Subscribes to entity snapshot changes so that the handle reference changes
323
- * when entity data changes. This is necessary because children may be wrapped
324
- * in React.memo and need a new handle reference to trigger re-renders.
322
+ * Subscribes to the entity snapshot so this host re-renders on data changes,
323
+ * which keeps inline reads in `children` fresh. The EntityHandle itself has a
324
+ * stable identity — it is intentionally NOT recreated on data changes — because
325
+ * it is a stateless live view over the store (see BaseHandle). Memoized leaf
326
+ * components (<Field>, <HasOne>, <HasMany>) re-render through their own store
327
+ * subscription (useField/useAccessor), not through a changing handle reference,
328
+ * so a data change re-renders only the leaves that read the changed data.
325
329
  */
326
330
  function EntityHandleRenderer({
327
331
  entityId,
@@ -342,11 +346,14 @@ function EntityHandleRenderer({
342
346
  [store, entityType, entityId],
343
347
  )
344
348
 
345
- const version = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
349
+ // Subscribe so this host re-renders on data changes (keeps inline reads in `children` fresh).
350
+ // The version is deliberately NOT fed into the handle memo below: the handle is a stable live
351
+ // view and must keep a stable identity across data changes.
352
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
346
353
 
347
354
  const rawHandle = useMemo(
348
355
  () => EntityHandle.createRaw(entityId, entityType, store, dispatcher, schemaRegistry, undefined, selection),
349
- [entityId, entityType, store, dispatcher, schemaRegistry, selection, version],
356
+ [entityId, entityType, store, dispatcher, schemaRegistry, selection],
350
357
  )
351
358
 
352
359
  const handle = useMemo(
@@ -354,13 +361,6 @@ function EntityHandleRenderer({
354
361
  [rawHandle],
355
362
  )
356
363
 
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.
363
-
364
364
  const result = children(handle as EntityAccessor<unknown>)
365
365
  return <>{annotateElement(result, { 'data-entity': entityType, 'data-entity-id': entityId })}</>
366
366
  }
@@ -22,13 +22,11 @@ import { annotateElement, isDevAnnotationsEnabled } from '../devAnnotations.js'
22
22
  * ```
23
23
  */
24
24
  function FieldImpl<T>({ field, children, format }: FieldProps<T>): ReactElement | null {
25
- // Handle undefined field (e.g., when accessing field on disconnected has-one relation)
26
- if (field === undefined || field === null) {
25
+ const accessor = useField(field ?? null)
26
+ if (accessor === null) {
27
27
  return null
28
28
  }
29
29
 
30
- // useField() subscribes to store and returns FieldAccessor with .value access
31
- const accessor = useField(field)
32
30
  const fieldName = field[FIELD_REF_META]?.fieldName
33
31
 
34
32
  if (children) {
@@ -3,6 +3,7 @@ import type { IfProps, SelectionFieldMeta, SelectionMeta, SelectionProvider, Fie
3
3
  import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js'
4
4
  import { mergeSelections, createEmptySelection } from '../SelectionMeta.js'
5
5
  import { useField } from '../../hooks/useField.js'
6
+ import { useRefSubscription } from '../../hooks/useFields.js'
6
7
  import {
7
8
  type Condition,
8
9
  isCondition,
@@ -11,6 +12,8 @@ import {
11
12
  CONDITION_META,
12
13
  } from '../conditions.js'
13
14
 
15
+ const NO_CONDITION_FIELDS: readonly unknown[] = []
16
+
14
17
  /**
15
18
  * If component - conditional rendering that ensures both branches are analyzed
16
19
  *
@@ -55,6 +58,9 @@ import {
55
58
  function IfImpl({ condition, then: thenBranch, else: elseBranch }: IfProps): ReactElement | null {
56
59
  const fieldRef = typeof condition !== 'boolean' && !isCondition(condition) ? condition : null
57
60
  const fieldAccessor = useField(fieldRef)
61
+ // A Condition is evaluated against live data, so it needs a subscription of its own —
62
+ // a memoized <If> is not re-rendered by its parent.
63
+ useRefSubscription(isCondition(condition) ? collectConditionFields(condition) : NO_CONDITION_FIELDS)
58
64
 
59
65
  let conditionValue: boolean
60
66
 
@@ -13,7 +13,7 @@ import type {
13
13
  SelectionProvider,
14
14
  } from '../types.js'
15
15
  import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js'
16
- import { useField } from '../../hooks/useField.js'
16
+ import { useFields, useRefSubscription } from '../../hooks/useFields.js'
17
17
  import {
18
18
  type Condition,
19
19
  isCondition,
@@ -132,6 +132,20 @@ function resolveTriggerField(props: CaseProps<unknown>): FieldRef<unknown> | nul
132
132
  return null
133
133
  }
134
134
 
135
+ /**
136
+ * Fields read by `if={cond...}` cases. They are evaluated against live data, so they need
137
+ * a subscription of their own — a memoized <Switch> is not re-rendered by its parent.
138
+ */
139
+ function collectConditionRefs(cases: readonly CaseEntry[]): unknown[] {
140
+ const refs: unknown[] = []
141
+ for (const { props } of cases) {
142
+ if ('if' in props && props.if !== undefined && isCondition(props.if)) {
143
+ refs.push(...collectConditionFields(props.if))
144
+ }
145
+ }
146
+ return refs
147
+ }
148
+
135
149
  // =============================================================================
136
150
  // Runtime
137
151
  // =============================================================================
@@ -139,17 +153,14 @@ function resolveTriggerField(props: CaseProps<unknown>): FieldRef<unknown> | nul
139
153
  function SwitchImpl({ children }: SwitchProps): ReactElement | null {
140
154
  const entries = extractEntries(children)
141
155
 
142
- // Subscribe to one field per case (stable count, stable order).
143
- const accessors: Array<{ value: unknown } | null> = []
144
- for (const entry of entries.cases) {
145
- const ref = resolveTriggerField(entry.props)
146
- // eslint-disable-next-line react-hooks/rules-of-hooks
147
- accessors.push(useField(ref))
148
- }
156
+ // Two fixed hooks cover every case, so the hook count never depends on how many
157
+ // <Case> children are rendered (a conditional <Case> used to crash React).
158
+ const accessors = useFields(entries.cases.map(entry => resolveTriggerField(entry.props)))
159
+ useRefSubscription(collectConditionRefs(entries.cases))
149
160
 
150
161
  for (let i = 0; i < entries.cases.length; i++) {
151
162
  const { props } = entries.cases[i]!
152
- const accessor = accessors[i]!
163
+ const accessor = accessors[i]
153
164
 
154
165
  if ('show' in props && props.show !== undefined) {
155
166
  const value = accessor?.value
@@ -32,11 +32,9 @@ import { createComponentBuilder } from './componentBuilder.js'
32
32
  * ))
33
33
  * ```
34
34
  */
35
- // eslint-disable-next-line @typescript-eslint/ban-types
36
35
  export function createComponent(): ComponentBuilder<ComponentBuilderState<{}, object, readonly string[]>>
37
36
  export function createComponent<const TRoles extends readonly string[]>(
38
37
  options: CreateComponentOptions<TRoles>,
39
- // eslint-disable-next-line @typescript-eslint/ban-types
40
38
  ): ComponentBuilder<ComponentBuilderState<{}, object, TRoles>>
41
39
  export function createComponent(options?: CreateComponentOptions<readonly string[]>): ComponentBuilder<ComponentBuilderState> {
42
40
  const roles = options?.roles ?? []