@contember/bindx-react 0.1.23 → 0.1.25

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 (52) hide show
  1. package/dist/hooks/index.d.ts +1 -0
  2. package/dist/hooks/index.d.ts.map +1 -1
  3. package/dist/hooks/useAccessor.d.ts +16 -0
  4. package/dist/hooks/useAccessor.d.ts.map +1 -0
  5. package/dist/hooks/useField.d.ts +1 -4
  6. package/dist/hooks/useField.d.ts.map +1 -1
  7. package/dist/hooks/useHasMany.d.ts +2 -5
  8. package/dist/hooks/useHasMany.d.ts.map +1 -1
  9. package/dist/hooks/useHasOne.d.ts +2 -5
  10. package/dist/hooks/useHasOne.d.ts.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/jsx/analyzer.d.ts.map +1 -1
  14. package/dist/jsx/componentBuilder.types.d.ts +16 -12
  15. package/dist/jsx/componentBuilder.types.d.ts.map +1 -1
  16. package/dist/jsx/componentFactory.d.ts.map +1 -1
  17. package/dist/jsx/components/Attribute.d.ts +38 -0
  18. package/dist/jsx/components/Attribute.d.ts.map +1 -0
  19. package/dist/jsx/components/Entity.d.ts +2 -2
  20. package/dist/jsx/components/Entity.d.ts.map +1 -1
  21. package/dist/jsx/components/EntityList.d.ts.map +1 -1
  22. package/dist/jsx/components/Field.d.ts.map +1 -1
  23. package/dist/jsx/components/HasMany.d.ts.map +1 -1
  24. package/dist/jsx/components/HasOne.d.ts.map +1 -1
  25. package/dist/jsx/components/index.d.ts +1 -0
  26. package/dist/jsx/components/index.d.ts.map +1 -1
  27. package/dist/jsx/devAnnotations.d.ts +12 -0
  28. package/dist/jsx/devAnnotations.d.ts.map +1 -0
  29. package/dist/jsx/index.d.ts +1 -0
  30. package/dist/jsx/index.d.ts.map +1 -1
  31. package/dist/jsx/types.d.ts +3 -3
  32. package/dist/jsx/types.d.ts.map +1 -1
  33. package/package.json +3 -3
  34. package/src/hooks/index.ts +1 -0
  35. package/src/hooks/useAccessor.ts +64 -0
  36. package/src/hooks/useField.ts +4 -20
  37. package/src/hooks/useHasMany.ts +4 -17
  38. package/src/hooks/useHasOne.ts +4 -17
  39. package/src/index.ts +3 -0
  40. package/src/jsx/analyzer.ts +1 -84
  41. package/src/jsx/componentBuilder.types.ts +31 -16
  42. package/src/jsx/componentFactory.ts +47 -5
  43. package/src/jsx/components/Attribute.tsx +90 -0
  44. package/src/jsx/components/Entity.tsx +5 -3
  45. package/src/jsx/components/EntityList.tsx +3 -1
  46. package/src/jsx/components/Field.tsx +11 -3
  47. package/src/jsx/components/HasMany.tsx +10 -1
  48. package/src/jsx/components/HasOne.tsx +5 -1
  49. package/src/jsx/components/index.ts +3 -0
  50. package/src/jsx/devAnnotations.ts +33 -0
  51. package/src/jsx/index.ts +1 -0
  52. package/src/jsx/types.ts +3 -3
@@ -1,14 +1,10 @@
1
- import { useSyncExternalStore } from 'react'
2
- import { type HasManyRef, type HasManyAccessor, FIELD_REF_META } from '@contember/bindx'
3
- import { useSnapshotStore } from './BackendAdapterContext.js'
4
- import type { AnyBrand } from '@contember/bindx'
1
+ import { type HasManyRef, type HasManyAccessor, type AnyBrand } from '@contember/bindx'
2
+ import { useAccessor } from './useAccessor.js'
5
3
 
6
4
  /**
7
5
  * Subscribes to a has-many ref and returns a HasManyAccessor with live collection access.
8
6
  *
9
- * At runtime, HasManyRef proxies already have .items/.length/.map —
10
- * this hook adds a store subscription so the component re-renders on changes,
11
- * and widens the type to HasManyAccessor.
7
+ * Thin wrapper over useAccessor for ergonomics.
12
8
  */
13
9
  export function useHasMany<
14
10
  TEntity,
@@ -17,14 +13,5 @@ export function useHasMany<
17
13
  TEntityName extends string = string,
18
14
  TSchema extends Record<string, object> = Record<string, object>,
19
15
  >(ref: HasManyRef<TEntity, TSelected, TBrand, TEntityName, TSchema>): HasManyAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema> {
20
- const store = useSnapshotStore()
21
- const meta = ref[FIELD_REF_META]
22
-
23
- useSyncExternalStore(
24
- (callback) => store.subscribeToEntity(meta.entityType, meta.entityId, callback),
25
- () => store.getVersion(),
26
- () => store.getVersion(),
27
- )
28
-
29
- return ref as unknown as HasManyAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>
16
+ return useAccessor(ref)
30
17
  }
@@ -1,14 +1,10 @@
1
- import { useSyncExternalStore } from 'react'
2
- import { type HasOneRef, type HasOneAccessor, FIELD_REF_META } from '@contember/bindx'
3
- import { useSnapshotStore } from './BackendAdapterContext.js'
4
- import type { AnyBrand } from '@contember/bindx'
1
+ import { type HasOneRef, type HasOneAccessor, type AnyBrand } from '@contember/bindx'
2
+ import { useAccessor } from './useAccessor.js'
5
3
 
6
4
  /**
7
5
  * Subscribes to a has-one ref and returns a HasOneAccessor with live state access.
8
6
  *
9
- * At runtime, HasOneRef proxies already have .$state/.$data/.$entity/.$fields —
10
- * this hook adds a store subscription so the component re-renders on changes,
11
- * and widens the type to HasOneAccessor.
7
+ * Thin wrapper over useAccessor for ergonomics.
12
8
  */
13
9
  export function useHasOne<
14
10
  TEntity,
@@ -17,14 +13,5 @@ export function useHasOne<
17
13
  TEntityName extends string = string,
18
14
  TSchema extends Record<string, object> = Record<string, object>,
19
15
  >(ref: HasOneRef<TEntity, TSelected, TBrand, TEntityName, TSchema>): HasOneAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema> {
20
- const store = useSnapshotStore()
21
- const meta = ref[FIELD_REF_META]
22
-
23
- useSyncExternalStore(
24
- (callback) => store.subscribeToEntity(meta.entityType, meta.entityId, callback),
25
- () => store.getVersion(),
26
- () => store.getVersion(),
27
- )
28
-
29
- return ref as unknown as HasOneAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>
16
+ return useAccessor(ref)
30
17
  }
package/src/index.ts CHANGED
@@ -297,6 +297,7 @@ export {
297
297
  useInterceptEntity,
298
298
  useInterceptField,
299
299
  // Ref → Accessor hooks
300
+ useAccessor,
300
301
  useField,
301
302
  useHasMany,
302
303
  useHasOne,
@@ -314,6 +315,8 @@ export type { UndoHookResult } from './hooks/useUndo.js'
314
315
 
315
316
  // JSX Components
316
317
  export {
318
+ Attribute,
319
+ type AttributeProps,
317
320
  Field,
318
321
  HasMany,
319
322
  HasOne,
@@ -1,10 +1,7 @@
1
1
  import type { ReactNode, ReactElement } from 'react'
2
2
  import { isValidElement } from 'react'
3
3
  import type { SelectionMeta, SelectionFieldMeta, SelectionProvider } from './types.js'
4
- import { FIELD_REF_META, SCOPE_REF } from './types.js'
5
4
  import { SelectionMetaCollector, mergeSelections } from './SelectionMeta.js'
6
- import { SelectionScope } from '@contember/bindx'
7
- import { isBindxComponent, COMPONENT_SELECTIONS } from './createComponent.js'
8
5
 
9
6
  /**
10
7
  * Analyzes JSX tree to extract field selection metadata.
@@ -47,24 +44,7 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
47
44
  // Handle function components
48
45
  const component = element.type as unknown
49
46
 
50
- // Check if it's a bindx component with pre-computed selection - needs special handling
51
- if (isBindxComponent(component)) {
52
- // First, trigger implicit collection if the component has getSelection
53
- // This ensures lazy implicit selections are collected before we access COMPONENT_SELECTIONS
54
- const componentObj = component as Record<string, unknown>
55
- if ('getSelection' in componentObj && typeof componentObj['getSelection'] === 'function') {
56
- ;(component as SelectionProvider).getSelection(element.props as Record<string, unknown>, () => new SelectionMetaCollector())
57
- }
58
-
59
- handleBindxComponent(
60
- component as { [COMPONENT_SELECTIONS]: Map<string, { selection: SelectionMeta }> },
61
- element.props as Record<string, unknown>,
62
- selection,
63
- )
64
- return
65
- }
66
-
67
- // Check if it's a component with getSelection (memo() wrapped or plain function components)
47
+ // Check if it's a component with getSelection (createComponent, Field, HasOne, HasMany, If, Show, etc.)
68
48
  if (
69
49
  component !== null &&
70
50
  (typeof component === 'object' || typeof component === 'function') &&
@@ -166,69 +146,6 @@ export function convertToQuerySelection(jsxSelection: SelectionMeta): Record<str
166
146
  return result
167
147
  }
168
148
 
169
- /**
170
- * Handles bindx component in JSX analysis.
171
- * Merges the component's pre-computed selection into the parent selection,
172
- * adjusted to the correct path context based on the entity prop.
173
- */
174
- function handleBindxComponent(
175
- component: { [COMPONENT_SELECTIONS]: Map<string, { selection: SelectionMeta }> },
176
- props: Record<string, unknown>,
177
- parentSelection: SelectionMetaCollector,
178
- ): void {
179
- const entityPropsMap = component[COMPONENT_SELECTIONS]
180
- // For each entity prop in the fragment component
181
- for (const [propName, meta] of entityPropsMap) {
182
- const propValue = props[propName]
183
-
184
- if (!propValue || typeof propValue !== 'object') {
185
- continue
186
- }
187
-
188
- // Case 1: Prop has SCOPE_REF - direct reference to SelectionScope
189
- // This is the case when passing relation.entity to a nested component
190
- if (SCOPE_REF in propValue) {
191
- const targetScope = (propValue as { [SCOPE_REF]: SelectionScope })[SCOPE_REF]
192
- // Merge the nested component's selection directly into the relation's scope
193
- targetScope.mergeFromSelectionMeta(meta.selection)
194
- }
195
- // Case 2: Prop is a FieldRef from a relation (e.g., article.fields.author)
196
- // This has FIELD_REF_META with a path to adjust
197
- else if (FIELD_REF_META in propValue) {
198
- const refMeta = (propValue as { [FIELD_REF_META]: { path: string[]; fieldName: string } })[FIELD_REF_META]
199
-
200
- // Add fields with adjusted paths to parent selection
201
- for (const [_key, field] of meta.selection.fields) {
202
- // Only process root-level fields from the fragment
203
- if (field.path.length === 1) {
204
- // Create a new field meta with the path adjusted to the parent context
205
- const adjustedField: SelectionFieldMeta = {
206
- ...field,
207
- path: [...refMeta.path, ...field.path],
208
- }
209
-
210
- // If the field has nested selection, we need to preserve it
211
- if (field.nested) {
212
- adjustedField.nested = field.nested
213
- }
214
-
215
- parentSelection.addField(adjustedField)
216
- }
217
- }
218
- }
219
- // Case 3: Prop is an EntityRef from Entity children callback (root level)
220
- // This doesn't have FIELD_REF_META, just merge the fields directly
221
- else if ('id' in propValue && 'fields' in propValue) {
222
- // This is an EntityRef - merge fragment's selection directly
223
- for (const [_key, field] of meta.selection.fields) {
224
- if (field.path.length === 1) {
225
- parentSelection.addField({ ...field })
226
- }
227
- }
228
- }
229
- }
230
- }
231
-
232
149
  /**
233
150
  * Debug helper - prints selection tree
234
151
  */
@@ -222,20 +222,32 @@ export type SetScalarProps<
222
222
  // ============================================================================
223
223
 
224
224
  /**
225
- * Build EntityAccessor props from entity config.
226
- *
227
- * For explicit selection (selector provided): Returns EntityAccessor with full access
228
- * - Allows direct field access: `entity.fieldName.value`
229
- * - Allows relation access: `entity.relation.length`, `entity.relation.$entity`
230
- *
231
- * For implicit selection (no selector): Returns EntityRef with restricted access
232
- * - Blocks direct field access to enforce declarative patterns
233
- * - Users must use components: `<Field field={entity.fieldName} />`
234
- * - Users must use condition DSL: `<If condition={cond.hasItems(entity.relation)} />`
225
+ * Build public entity props — always EntityRef (stable pointer, no live data).
226
+ * Used for the component's external React props.
235
227
  */
236
228
  export type BuildEntityProps<
237
229
  TEntityProps extends Record<string, AnyEntityPropConfig>,
238
230
  TRoles extends readonly string[],
231
+ > = {
232
+ readonly [K in keyof TEntityProps]: TEntityProps[K] extends EntityPropConfig<
233
+ infer TEntity,
234
+ infer TSelected,
235
+ infer _TIsImplicit
236
+ >
237
+ ? EntityRef<TEntity, TSelected, AnyBrand, string>
238
+ : TEntityProps[K] extends InterfaceEntityPropConfig<infer TInterface, infer _TIsImplicit>
239
+ ? EntityRef<TInterface, TInterface, AnyBrand, string>
240
+ : never
241
+ }
242
+
243
+ /**
244
+ * Build render entity props — explicit selection → EntityAccessor (live data),
245
+ * implicit selection → EntityRef (pointer only, use <Field> components).
246
+ * Used for the render function's props type.
247
+ */
248
+ export type BuildRenderEntityProps<
249
+ TEntityProps extends Record<string, AnyEntityPropConfig>,
250
+ TRoles extends readonly string[],
239
251
  > = {
240
252
  readonly [K in keyof TEntityProps]: TEntityProps[K] extends EntityPropConfig<
241
253
  infer TEntity,
@@ -243,26 +255,29 @@ export type BuildEntityProps<
243
255
  infer TIsImplicit
244
256
  >
245
257
  ? TIsImplicit extends true
246
- // Implicit selection -> restricted EntityRef (no .value, .length access)
247
258
  ? EntityRef<TEntity, TSelected, AnyBrand, string>
248
- // Explicit selection -> full EntityAccessor (full access)
249
259
  : EntityAccessor<TEntity, TSelected, AnyBrand, string>
250
260
  : TEntityProps[K] extends InterfaceEntityPropConfig<infer TInterface, infer TIsImplicit>
251
261
  ? TIsImplicit extends true
252
- // Implicit interface -> restricted
253
262
  ? EntityRef<TInterface, TInterface, AnyBrand, string>
254
- // Explicit interface -> full access
255
263
  : EntityAccessor<TInterface, TInterface, AnyBrand, string>
256
264
  : never
257
265
  }
258
266
 
259
267
  /**
260
- * Build complete props type from builder state.
268
+ * Build complete public props type from builder state.
261
269
  */
262
270
  export type BuildProps<TState extends ComponentBuilderState> =
263
271
  TState['__scalarProps'] &
264
272
  BuildEntityProps<TState['__entityProps'], TState['__roles']>
265
273
 
274
+ /**
275
+ * Build complete render props type from builder state.
276
+ */
277
+ export type BuildRenderProps<TState extends ComponentBuilderState> =
278
+ TState['__scalarProps'] &
279
+ BuildRenderEntityProps<TState['__entityProps'], TState['__roles']>
280
+
266
281
  /**
267
282
  * Build fragment properties ($propName) from entity config.
268
283
  */
@@ -437,7 +452,7 @@ export interface ComponentBuilder<
437
452
  * @param renderFn - React render function receiving typed props
438
453
  * @returns Bindx component with fragment properties
439
454
  */
440
- render(renderFn: (props: BuildProps<TState>) => ReactNode): BindxComponent<TState>
455
+ render(renderFn: (props: BuildRenderProps<TState>) => ReactNode): BindxComponent<TState>
441
456
  }
442
457
 
443
458
  // ============================================================================
@@ -15,6 +15,7 @@ import type {
15
15
  SelectionMeta,
16
16
  SelectionBuilder,
17
17
  AnyBrand,
18
+ EntityRef,
18
19
  SchemaDefinition,
19
20
  } from '@contember/bindx'
20
21
  import {
@@ -28,10 +29,11 @@ import type {
28
29
  SelectionPropMeta,
29
30
  } from './componentBuilder.types.js'
30
31
  import type { SelectionProvider, SelectionFieldMeta } from './types.js'
31
- import { FIELD_REF_META, BINDX_COMPONENT } from './types.js'
32
+ import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from './types.js'
32
33
  import { createCollectorProxy } from './proxy.js'
33
34
  import { collectSelection } from './analyzer.js'
34
35
  import { type Condition, evaluateCondition } from './conditions.js'
36
+ import { useAccessor } from '../hooks/useAccessor.js'
35
37
 
36
38
  // ============================================================================
37
39
  // Symbols
@@ -63,6 +65,24 @@ export interface EntityConfig {
63
65
  readonly schema?: SchemaDefinition<Record<string, object>>
64
66
  }
65
67
 
68
+ // ============================================================================
69
+ // Render Props Transformation
70
+ // ============================================================================
71
+
72
+ /**
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.
75
+ */
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
84
+ }
85
+
66
86
  // ============================================================================
67
87
  // Component Building
68
88
  // ============================================================================
@@ -100,6 +120,11 @@ export function buildComponent<TProps extends object>(
100
120
  }
101
121
  }
102
122
 
123
+ // Collect explicit entity prop names (stable list for hooks)
124
+ const explicitEntityPropNames = [...entityConfigs.entries()]
125
+ .filter(([_, c]) => c.selector)
126
+ .map(([name]) => name)
127
+
103
128
  // 2. Implicit entities - collect lazily to avoid TDZ errors
104
129
  const implicitConfigs = [...entityConfigs.entries()].filter(([_, c]) => !c.selector)
105
130
  let implicitCollected = false
@@ -118,14 +143,20 @@ export function buildComponent<TProps extends object>(
118
143
  // 3. Create React component
119
144
  function ComponentImpl(props: TProps): ReactNode {
120
145
  ensureImplicitCollected()
146
+
147
+ // Convert explicit entity refs to accessors (stable hook count — explicitEntityPropNames is fixed)
148
+ const renderProps = explicitEntityPropNames.length > 0
149
+ ? useRenderProps(props, explicitEntityPropNames)
150
+ : props
151
+
121
152
  // Evaluate condition at runtime
122
153
  if (conditionFn) {
123
- const condition = conditionFn(props)
154
+ const condition = conditionFn(renderProps)
124
155
  if (!evaluateCondition(condition)) {
125
156
  return null
126
157
  }
127
158
  }
128
- return renderFn(props)
159
+ return renderFn(renderProps)
129
160
  }
130
161
 
131
162
  const MemoizedComponent = memo(ComponentImpl)
@@ -372,8 +403,19 @@ function createGetSelection(
372
403
  for (const [propName, meta] of selectionsMap) {
373
404
  const propValue = props[propName]
374
405
 
406
+ if (!propValue || typeof propValue !== 'object') {
407
+ continue
408
+ }
409
+
410
+ // Case 0: Collection phase — prop has SCOPE_REF, merge directly into scope
411
+ if (SCOPE_REF in propValue) {
412
+ const targetScope = (propValue as { [SCOPE_REF]: SelectionScope })[SCOPE_REF]
413
+ targetScope.mergeFromSelectionMeta(meta.selection)
414
+ continue
415
+ }
416
+
375
417
  // Case 1: Prop is a field reference (from relation)
376
- if (propValue && typeof propValue === 'object' && FIELD_REF_META in propValue) {
418
+ if (FIELD_REF_META in propValue) {
377
419
  const refMeta = (propValue as { [FIELD_REF_META]: { path: string[]; fieldName: string } })[FIELD_REF_META]
378
420
 
379
421
  for (const [_key, field] of meta.selection.fields) {
@@ -386,7 +428,7 @@ function createGetSelection(
386
428
  }
387
429
  }
388
430
  // Case 2: Prop is an EntityRef from root level
389
- else if (propValue && typeof propValue === 'object' && 'id' in propValue && 'fields' in propValue) {
431
+ else if ('id' in propValue && 'fields' in propValue) {
390
432
  for (const [_key, field] of meta.selection.fields) {
391
433
  if (field.path.length === 1) {
392
434
  fields.push({ ...field })
@@ -0,0 +1,90 @@
1
+ import { cloneElement, isValidElement, memo, type ReactElement, type ReactNode } from 'react'
2
+ import type { FieldAccessor, FieldRef, SelectionFieldMeta, SelectionMeta, AnyBrand } from '../types.js'
3
+ import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js'
4
+ import type { SelectionProvider } from '../types.js'
5
+ import { useField } from '../../hooks/useField.js'
6
+ import { isDevAnnotationsEnabled } from '../devAnnotations.js'
7
+
8
+ /**
9
+ * Props for Attribute component.
10
+ */
11
+ export interface AttributeProps<T> {
12
+ /** The field to subscribe to */
13
+ field: FieldRef<T>
14
+ /** Maps the field accessor to props that get spread onto the child element */
15
+ format: (accessor: FieldAccessor<T>) => Record<string, unknown>
16
+ /** Single child element — receives the formatted props via cloneElement */
17
+ children: ReactElement
18
+ }
19
+
20
+ /**
21
+ * Attribute component — applies field-derived props to a child element.
22
+ *
23
+ * Like `<Field>` but for attributes (style, className, data-*) instead of text content.
24
+ * Subscribes to the field, calls `format` to produce props, and clones the child with merged props.
25
+ * During selection collection, both the field and children's fields are tracked.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <Attribute field={tag.color} format={color => ({ style: { backgroundColor: color.value ?? '#666' } })}>
30
+ * <span>
31
+ * <Field field={tag.name} />
32
+ * </span>
33
+ * </Attribute>
34
+ * ```
35
+ */
36
+ function AttributeImpl<T>({ field, format, children }: AttributeProps<T>): ReactElement | null {
37
+ if (field === undefined || field === null) {
38
+ return children
39
+ }
40
+
41
+ const accessor = useField(field)
42
+ const extraProps = {
43
+ ...format(accessor),
44
+ ...(isDevAnnotationsEnabled() ? { 'data-field': field[FIELD_REF_META]?.fieldName } : {}),
45
+ }
46
+
47
+ if (!isValidElement(children)) {
48
+ return children
49
+ }
50
+
51
+ return cloneElement(children, extraProps)
52
+ }
53
+
54
+ export const Attribute = memo(AttributeImpl) as typeof AttributeImpl
55
+
56
+ // Static method for selection extraction
57
+ const attributeWithSelection = Attribute as typeof Attribute & SelectionProvider & { [BINDX_COMPONENT]: true }
58
+
59
+ attributeWithSelection.getSelection = (
60
+ props: AttributeProps<unknown>,
61
+ collectNested: (children: ReactNode) => SelectionMeta,
62
+ ): SelectionFieldMeta | null => {
63
+ if (props.field === undefined || props.field === null) {
64
+ return null
65
+ }
66
+
67
+ const meta = props.field[FIELD_REF_META]
68
+ if (!meta) {
69
+ return null
70
+ }
71
+
72
+ // During collection phase, skip — field access is already tracked by the collector proxy
73
+ if (meta.entityId === '') {
74
+ // But still analyze children for nested field selections
75
+ collectNested(props.children)
76
+ return null
77
+ }
78
+
79
+ return {
80
+ fieldName: meta.fieldName,
81
+ alias: meta.fieldName,
82
+ path: meta.path,
83
+ isArray: false,
84
+ isRelation: false,
85
+ }
86
+ }
87
+
88
+ attributeWithSelection[BINDX_COMPONENT] = true
89
+
90
+ export { attributeWithSelection as AttributeWithMeta }
@@ -1,8 +1,9 @@
1
1
  import React, { memo, useCallback, useEffect, useMemo, useRef, useSyncExternalStore, type ReactElement } from 'react'
2
2
  import { useBindxContext, useSchemaRegistry } from '../../hooks/BackendAdapterContext.js'
3
+ import { annotateElement } from '../devAnnotations.js'
3
4
  import { useEntity } from '../../hooks/useEntity.js'
4
5
  import { useSelectionCollection } from '../../hooks/useSelectionCollection.js'
5
- import type { EntityAccessor, SelectionMeta } from '../types.js'
6
+ import type { EntityAccessor, EntityRef, SelectionMeta } from '../types.js'
6
7
  import { type EntityDef, type EntityUniqueWhere, type AnyBrand, type FieldError, EntityHandle, type SnapshotStore, type ActionDispatcher, type SchemaRegistry, type CommonEntity } from '@contember/bindx'
7
8
 
8
9
  // ==================== Props Types ====================
@@ -15,7 +16,7 @@ interface EntityBaseProps<TRoleMap extends Record<string, object>> {
15
16
  /** Entity definition reference */
16
17
  entity: EntityDef<TRoleMap>
17
18
  /** Render function receiving typed entity accessor with direct field access */
18
- children: (entity: EntityAccessor<CommonEntity<TRoleMap>>) => React.ReactNode
19
+ children: (entity: EntityRef<CommonEntity<TRoleMap>>) => React.ReactNode
19
20
  /** Error fallback */
20
21
  error?: (error: FieldError) => React.ReactNode
21
22
  }
@@ -284,7 +285,8 @@ function EntityHandleRenderer({
284
285
 
285
286
  useEffect(() => () => { rawHandle.dispose() }, [rawHandle])
286
287
 
287
- return <>{children(handle as EntityAccessor<unknown>)}</>
288
+ const result = children(handle as EntityAccessor<unknown>)
289
+ return <>{annotateElement(result, { 'data-entity': entityType, 'data-entity-id': entityId })}</>
288
290
  }
289
291
 
290
292
  // ==================== Main Entity Component ====================
@@ -3,6 +3,7 @@ import { type EntityDef, type EntityWhere, type EntityOrderBy, type FieldError,
3
3
  import { useEntityList } from '../../hooks/useEntityList.js'
4
4
  import { useSelectionCollection } from '../../hooks/useSelectionCollection.js'
5
5
  import type { EntityAccessor } from '../types.js'
6
+ import { annotateElement } from '../devAnnotations.js'
6
7
 
7
8
  /**
8
9
  * Props for EntityList component
@@ -104,9 +105,10 @@ function EntityListComponent<TRoleMap extends Record<string, object>>({
104
105
 
105
106
  // Phase 3: Runtime render — items are already EntityAccessors from the hook
106
107
  const items = result.items.map((item: (typeof result.items)[number], index: number) => {
108
+ const result = children(item as unknown as EntityAccessor<CommonEntity<TRoleMap>>, index)
107
109
  return (
108
110
  <React.Fragment key={item.id}>
109
- {children(item as unknown as EntityAccessor<CommonEntity<TRoleMap>>, index)}
111
+ {annotateElement(result, { 'data-entity': entityType, 'data-entity-id': item.id })}
110
112
  </React.Fragment>
111
113
  )
112
114
  })
@@ -2,6 +2,7 @@ import { memo, type ReactElement } from 'react'
2
2
  import type { FieldProps, SelectionFieldMeta, SelectionProvider } from '../types.js'
3
3
  import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js'
4
4
  import { useField } from '../../hooks/useField.js'
5
+ import { annotateElement, isDevAnnotationsEnabled } from '../devAnnotations.js'
5
6
 
6
7
  /**
7
8
  * Field component - renders a scalar field value
@@ -28,13 +29,18 @@ function FieldImpl<T>({ field, children, format }: FieldProps<T>): ReactElement
28
29
 
29
30
  // useField() subscribes to store and returns FieldAccessor with .value access
30
31
  const accessor = useField(field)
32
+ const fieldName = field[FIELD_REF_META]?.fieldName
31
33
 
32
34
  if (children) {
33
- return <>{children(accessor)}</>
35
+ const result = children(accessor)
36
+ return <>{annotateElement(result, { 'data-field': fieldName })}</>
34
37
  }
35
38
 
36
39
  if (format) {
37
- return <>{format(accessor.value)}</>
40
+ const content = format(accessor.value)
41
+ return isDevAnnotationsEnabled() && fieldName
42
+ ? <span data-field={fieldName}>{content}</span>
43
+ : <>{content}</>
38
44
  }
39
45
 
40
46
  // Default: render value as string
@@ -42,7 +48,9 @@ function FieldImpl<T>({ field, children, format }: FieldProps<T>): ReactElement
42
48
  return null
43
49
  }
44
50
 
45
- return <>{String(accessor.value)}</>
51
+ return isDevAnnotationsEnabled() && fieldName
52
+ ? <span data-field={fieldName}>{String(accessor.value)}</span>
53
+ : <>{String(accessor.value)}</>
46
54
  }
47
55
 
48
56
  export const Field = memo(FieldImpl) as typeof FieldImpl
@@ -4,6 +4,7 @@ import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from '../types.js'
4
4
  import { mergeSelections } from '../SelectionMeta.js'
5
5
  import { SelectionScope, generateHasManyAlias, type HasManyAccessor } from '@contember/bindx'
6
6
  import { useHasMany } from '../../hooks/useHasMany.js'
7
+ import { annotateElement } from '../devAnnotations.js'
7
8
 
8
9
  /**
9
10
  * HasMany component - renders a has-many relation
@@ -38,8 +39,16 @@ function HasManyImpl<
38
39
  >({ field, children }: HasManyProps<TEntity, TSelected, TBrand, TEntityName, TSchema>): ReactElement {
39
40
  // useHasMany() subscribes to store and returns HasManyAccessor with .map()/.items/.length
40
41
  const accessor = useHasMany(field)
42
+ const targetType = field[FIELD_REF_META]?.targetType
43
+
41
44
  const items = accessor.map((item, index) => {
42
- return <React.Fragment key={item.id}>{children(item, index)}</React.Fragment>
45
+ const result = children(item, index)
46
+ return <React.Fragment key={item.id}>{
47
+ annotateElement(result, {
48
+ 'data-entity': targetType ?? '',
49
+ 'data-entity-id': item.id,
50
+ })
51
+ }</React.Fragment>
43
52
  })
44
53
 
45
54
  return <>{items}</>
@@ -4,6 +4,7 @@ import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from '../types.js'
4
4
  import { mergeSelections } from '../SelectionMeta.js'
5
5
  import { SelectionScope, type HasOneAccessor } from '@contember/bindx'
6
6
  import { useHasOne } from '../../hooks/useHasOne.js'
7
+ import { annotateElement } from '../devAnnotations.js'
7
8
 
8
9
  /**
9
10
  * HasOne component - renders a has-one relation
@@ -29,8 +30,11 @@ function HasOneImpl<
29
30
  >({ field, children }: HasOneProps<TEntity, TSelected, TBrand, TEntityName, TSchema>): ReactElement {
30
31
  // useHasOne() subscribes to store and returns HasOneAccessor with .$entity
31
32
  const accessor = useHasOne(field)
33
+ const relationName = field[FIELD_REF_META]?.fieldName
34
+
32
35
  // Get the related entity reference (always available, may be placeholder with id=null)
33
- return <>{children(accessor.$entity)}</>
36
+ const result = children(accessor.$entity)
37
+ return <>{annotateElement(result, { 'data-relation': relationName ?? '' })}</>
34
38
  }
35
39
 
36
40
  export const HasOne = memo(HasOneImpl) as typeof HasOneImpl
@@ -1,3 +1,6 @@
1
+ // Attribute component
2
+ export { Attribute, AttributeWithMeta, type AttributeProps } from './Attribute.js'
3
+
1
4
  // Field component
2
5
  export { Field, FieldWithMeta } from './Field.js'
3
6
 
@@ -0,0 +1,33 @@
1
+ import { cloneElement, isValidElement, type ReactNode } from 'react'
2
+
3
+ /**
4
+ * Whether dev-mode data attributes are enabled.
5
+ *
6
+ * Controlled via `BINDX_DEV_ANNOTATIONS` env variable.
7
+ * Bundlers (Vite, webpack) replace `import.meta.env` at build time,
8
+ * so this entire module tree-shakes away in production builds.
9
+ */
10
+ // @ts-ignore -- import.meta.env is provided by bundler
11
+ const DEV_ANNOTATIONS: boolean = typeof import.meta !== 'undefined' && import.meta.env?.VITE_BINDX_DEV_ANNOTATIONS === 'true'
12
+
13
+ /**
14
+ * In dev mode, injects data attributes onto an existing React element via cloneElement.
15
+ * If annotations are disabled or the node is not a valid element, returns as-is.
16
+ */
17
+ export function annotateElement(
18
+ element: ReactNode,
19
+ attrs: Record<string, string>,
20
+ ): ReactNode {
21
+ if (!DEV_ANNOTATIONS || !isValidElement(element)) {
22
+ return element
23
+ }
24
+ return cloneElement(element, attrs)
25
+ }
26
+
27
+ /**
28
+ * Returns true if dev annotations are enabled.
29
+ * Use for conditional wrapping (e.g., Field text render in span).
30
+ */
31
+ export function isDevAnnotationsEnabled(): boolean {
32
+ return DEV_ANNOTATIONS
33
+ }
package/src/jsx/index.ts CHANGED
@@ -52,6 +52,7 @@ export {
52
52
  } from './analyzer.js'
53
53
 
54
54
  // Components
55
+ export { Attribute, AttributeWithMeta, type AttributeProps } from './components/Attribute.js'
55
56
  export { Field, FieldWithMeta } from './components/Field.js'
56
57
  export { HasMany, HasManyWithMeta } from './components/HasMany.js'
57
58
  export { HasOne, HasOneWithMeta } from './components/HasOne.js'