@contember/bindx-react 0.1.57 → 0.1.59

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 (44) hide show
  1. package/dist/hooks/useRootSelection.d.ts +22 -0
  2. package/dist/hooks/useRootSelection.d.ts.map +1 -0
  3. package/dist/index.d.ts +3 -3
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/jsx/analyzer.d.ts +5 -0
  6. package/dist/jsx/analyzer.d.ts.map +1 -1
  7. package/dist/jsx/collectionHelpers.d.ts +20 -0
  8. package/dist/jsx/collectionHelpers.d.ts.map +1 -0
  9. package/dist/jsx/collectorContract.d.ts +25 -0
  10. package/dist/jsx/collectorContract.d.ts.map +1 -0
  11. package/dist/jsx/compiledSelection.d.ts +80 -0
  12. package/dist/jsx/compiledSelection.d.ts.map +1 -0
  13. package/dist/jsx/compiledSelectionShape.d.ts +12 -0
  14. package/dist/jsx/compiledSelectionShape.d.ts.map +1 -0
  15. package/dist/jsx/componentBuilder.d.ts +5 -2
  16. package/dist/jsx/componentBuilder.d.ts.map +1 -1
  17. package/dist/jsx/componentBuilder.types.d.ts +32 -1
  18. package/dist/jsx/componentBuilder.types.d.ts.map +1 -1
  19. package/dist/jsx/componentFactory.d.ts +22 -2
  20. package/dist/jsx/componentFactory.d.ts.map +1 -1
  21. package/dist/jsx/components/Entity.d.ts +7 -0
  22. package/dist/jsx/components/Entity.d.ts.map +1 -1
  23. package/dist/jsx/index.d.ts +4 -0
  24. package/dist/jsx/index.d.ts.map +1 -1
  25. package/dist/jsx/proxyShared.d.ts +8 -0
  26. package/dist/jsx/proxyShared.d.ts.map +1 -1
  27. package/dist/jsx/withCollector.d.ts +7 -8
  28. package/dist/jsx/withCollector.d.ts.map +1 -1
  29. package/package.json +3 -3
  30. package/src/hooks/useRootSelection.ts +111 -0
  31. package/src/index.ts +13 -0
  32. package/src/jsx/analyzer.ts +10 -2
  33. package/src/jsx/collectionHelpers.ts +55 -0
  34. package/src/jsx/collectorContract.tsx +71 -0
  35. package/src/jsx/collectorProxy.ts +7 -1
  36. package/src/jsx/compiledSelection.ts +353 -0
  37. package/src/jsx/compiledSelectionShape.ts +86 -0
  38. package/src/jsx/componentBuilder.ts +28 -1
  39. package/src/jsx/componentBuilder.types.ts +38 -1
  40. package/src/jsx/componentFactory.ts +212 -42
  41. package/src/jsx/components/Entity.tsx +27 -8
  42. package/src/jsx/index.ts +18 -0
  43. package/src/jsx/proxyShared.ts +14 -0
  44. package/src/jsx/withCollector.ts +25 -10
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Runtime shape check for a compiler-emitted {@link CompiledSelection} literal.
3
+ * A stale or corrupt literal must never be half-read into the fetch plan —
4
+ * consumers fall back to the runtime proxy pass when this check fails.
5
+ */
6
+ import type { CompiledSelection } from './compiledSelection.js'
7
+
8
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
10
+ }
11
+
12
+ function isStringArray(value: unknown): boolean {
13
+ return Array.isArray(value) && value.every(item => typeof item === 'string')
14
+ }
15
+
16
+ function isValidFieldMap(value: unknown): boolean {
17
+ return isPlainObject(value) && Object.values(value).every(isValidFieldNode)
18
+ }
19
+
20
+ function isValidOptionalFieldMap(value: unknown): boolean {
21
+ return value === undefined || isValidFieldMap(value)
22
+ }
23
+
24
+ /** Only known keys: a literal from a newer emitter that still says `v: 2` must not be half-read. */
25
+ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
26
+ return Object.keys(value).every(key => allowed.includes(key))
27
+ }
28
+
29
+ function isValidFieldNode(node: unknown): boolean {
30
+ if (node === true) {
31
+ return true
32
+ }
33
+ if (!isPlainObject(node) || !hasOnlyKeys(node, ['fields', 'hasOneFields', 'many', 'assumesNotHasOne'])) {
34
+ return false
35
+ }
36
+ if (node['fields'] === undefined && node['hasOneFields'] === undefined && node['assumesNotHasOne'] === undefined) {
37
+ return false
38
+ }
39
+ return isValidOptionalFieldMap(node['fields'])
40
+ && isValidOptionalFieldMap(node['hasOneFields'])
41
+ && (node['many'] === undefined || node['many'] === true)
42
+ && (node['assumesNotHasOne'] === undefined || node['assumesNotHasOne'] === true)
43
+ }
44
+
45
+ function isValidThunkMap(value: unknown): boolean {
46
+ return value === undefined
47
+ || (isPlainObject(value) && Object.values(value).every(thunk => typeof thunk === 'function'))
48
+ }
49
+
50
+ function isValidHole(hole: unknown, entityPropNames: readonly string[]): boolean {
51
+ if (!isPlainObject(hole) || !hasOnlyKeys(hole, ['component', 'entityProps', 'literalProps', 'extraProps'])) {
52
+ return false
53
+ }
54
+ if (typeof hole['component'] !== 'function' || !isPlainObject(hole['entityProps'])) {
55
+ return false
56
+ }
57
+ const hasValidOrigins = Object.values(hole['entityProps']).every(origin =>
58
+ isPlainObject(origin)
59
+ && hasOnlyKeys(origin, ['source', 'path'])
60
+ && typeof origin['source'] === 'string'
61
+ && entityPropNames.includes(origin['source'])
62
+ && isStringArray(origin['path']),
63
+ )
64
+ return hasValidOrigins
65
+ && (hole['literalProps'] === undefined || isPlainObject(hole['literalProps']))
66
+ && isValidThunkMap(hole['extraProps'])
67
+ }
68
+
69
+ /**
70
+ * True when `value` is a v2 literal whose field maps and holes are well-formed and
71
+ * reference only `entityPropNames` (the host's implicit entity props; `['entity']` for <Entity>).
72
+ */
73
+ export function isValidCompiledSelection(
74
+ value: unknown,
75
+ entityPropNames: readonly string[],
76
+ ): value is CompiledSelection {
77
+ if (!isPlainObject(value) || !hasOnlyKeys(value, ['v', 'props', 'holes']) || value['v'] !== 2 || !isPlainObject(value['props'])) {
78
+ return false
79
+ }
80
+ const hasValidProps = Object.entries(value['props']).every(([propName, fieldMap]) =>
81
+ entityPropNames.includes(propName) && isValidFieldMap(fieldMap),
82
+ )
83
+ const holes = value['holes']
84
+ return hasValidProps
85
+ && (holes === undefined || (Array.isArray(holes) && holes.every(hole => isValidHole(hole, entityPropNames))))
86
+ }
@@ -19,6 +19,7 @@ import type {
19
19
  ComponentBuilderState,
20
20
  CreateComponentOptions,
21
21
  } from './componentBuilder.types.js'
22
+ import type { CompiledSelection } from './compiledSelection.js'
22
23
  export type { SelectionPropMeta } from './componentBuilder.types.js'
23
24
  import type { Condition } from './conditions.js'
24
25
  import { buildComponent, type EntityConfig } from './componentFactory.js'
@@ -50,6 +51,7 @@ export class ComponentBuilderImpl<
50
51
  private readonly conditionFn: ((props: Record<string, unknown>) => Condition) | null = null,
51
52
  private readonly slotNames: readonly string[] = ['children'],
52
53
  private readonly useFns: readonly ((props: Record<string, unknown>) => object)[] = [],
54
+ private readonly mockValues: Record<string, unknown> = {},
53
55
  ) {}
54
56
 
55
57
  entity(
@@ -69,6 +71,7 @@ export class ComponentBuilderImpl<
69
71
  this.conditionFn,
70
72
  this.slotNames,
71
73
  this.useFns,
74
+ this.mockValues,
72
75
  )
73
76
  }
74
77
 
@@ -99,6 +102,7 @@ export class ComponentBuilderImpl<
99
102
  this.conditionFn,
100
103
  this.slotNames,
101
104
  this.useFns,
105
+ this.mockValues,
102
106
  )
103
107
  }
104
108
 
@@ -112,6 +116,7 @@ export class ComponentBuilderImpl<
112
116
  this.conditionFn,
113
117
  this.slotNames,
114
118
  this.useFns,
119
+ this.mockValues,
115
120
  )
116
121
  }
117
122
 
@@ -124,6 +129,21 @@ export class ComponentBuilderImpl<
124
129
  this.conditionFn,
125
130
  this.slotNames,
126
131
  [...this.useFns, useFn],
132
+ this.mockValues,
133
+ )
134
+ }
135
+
136
+ mock(values: Record<string, unknown>): ComponentBuilderImpl<TState> {
137
+ // Later calls merge over earlier ones
138
+ return new ComponentBuilderImpl(
139
+ this.schemaRegistry,
140
+ this.entityConfigs,
141
+ this.roles,
142
+ this.hasInterfacesMode,
143
+ this.conditionFn,
144
+ this.slotNames,
145
+ this.useFns,
146
+ { ...this.mockValues, ...values },
127
147
  )
128
148
  }
129
149
 
@@ -136,6 +156,7 @@ export class ComponentBuilderImpl<
136
156
  conditionFn,
137
157
  this.slotNames,
138
158
  this.useFns,
159
+ this.mockValues,
139
160
  )
140
161
  }
141
162
 
@@ -148,10 +169,14 @@ export class ComponentBuilderImpl<
148
169
  this.conditionFn,
149
170
  names,
150
171
  this.useFns,
172
+ this.mockValues,
151
173
  )
152
174
  }
153
175
 
154
- render(renderFn: (props: Record<string, unknown>) => ReactNode): unknown {
176
+ render(
177
+ renderFn: (props: Record<string, unknown>) => ReactNode,
178
+ compiled?: CompiledSelection,
179
+ ): unknown {
155
180
  return buildComponent(
156
181
  this.entityConfigs,
157
182
  this.roles,
@@ -161,6 +186,8 @@ export class ComponentBuilderImpl<
161
186
  this.conditionFn,
162
187
  this.slotNames,
163
188
  this.useFns,
189
+ this.mockValues,
190
+ compiled ?? null,
164
191
  )
165
192
  }
166
193
  }
@@ -19,6 +19,7 @@ import type {
19
19
  } from '@contember/bindx'
20
20
  import type { SelectionProvider } from './types.js'
21
21
  import type { Condition } from './conditions.js'
22
+ import type { CompiledSelection } from './compiledSelection.js'
22
23
 
23
24
  // ============================================================================
24
25
  // Symbols (re-exported from createComponent.ts)
@@ -480,6 +481,34 @@ export interface ComponentBuilder<
480
481
  useFn: (props: BuildRenderProps<TState>) => TUse,
481
482
  ): ComponentBuilder<AddUseProps<TState, TUse>>
482
483
 
484
+ /**
485
+ * Supply deterministic stand-in values for scalar / `.use()` props used
486
+ * EXCLUSIVELY during static selection analysis — never at runtime render,
487
+ * where real props and real `.use()` outputs always win.
488
+ *
489
+ * The generic tolerant stand-in survives most code but under-collects when a
490
+ * mocked value drives control flow: indexing a real object with a mocked key
491
+ * (`LABELS[key].x` crashes), branching on a scalar (nondeterministic branch),
492
+ * or `.map()` over a scalar array (callback never runs, so entity fields
493
+ * accessed inside it are silently missed). A concrete mock fixes all three.
494
+ *
495
+ * Later calls merge over earlier ones. Entity props cannot be mocked (type error).
496
+ * Does not change the builder state type.
497
+ *
498
+ * @example
499
+ * ```typescript
500
+ * createComponent()
501
+ * .entity('article', schema.Article)
502
+ * .props<{ labelKey: string; tabs: { key: string }[] }>()
503
+ * .use(() => ({ t: useTranslator() }))
504
+ * .mock({ labelKey: 'k1', tabs: [{ key: 'x' }], t: key => key })
505
+ * .render(({ article, labelKey, tabs, t }) => ...)
506
+ * ```
507
+ */
508
+ mock(
509
+ values: Partial<Omit<BuildRenderProps<TState>, keyof TState['__entityProps']>>,
510
+ ): ComponentBuilder<TState>
511
+
483
512
  /**
484
513
  * Add a condition that must be true for the component to render.
485
514
  * If the condition is false, the component renders null.
@@ -531,9 +560,17 @@ export interface ComponentBuilder<
531
560
  * Build the component with the render function.
532
561
  *
533
562
  * @param renderFn - React render function receiving typed props
563
+ * @param compiled - Precompiled selection injected by the selection compiler
564
+ * (per-prop static field maps + nested-component holes). Compiler-facing
565
+ * only — do NOT hand-write it. When present, the runtime skips the proxy
566
+ * collection pass and uses this instead; enable
567
+ * {@link setStaticSelectionValidation} in dev/CI to cross-check it.
534
568
  * @returns Bindx component with fragment properties
535
569
  */
536
- render(renderFn: (props: BuildRenderProps<TState>) => ReactNode): BindxComponent<TState>
570
+ render(
571
+ renderFn: (props: BuildRenderProps<TState>) => ReactNode,
572
+ compiled?: CompiledSelection,
573
+ ): BindxComponent<TState>
537
574
  }
538
575
 
539
576
  // ============================================================================
@@ -31,6 +31,9 @@ import type { SelectionProvider, SelectionFieldMeta } from './types.js'
31
31
  import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from './types.js'
32
32
  import { createCollectorProxy } from './proxy.js'
33
33
  import { collectSelection } from './analyzer.js'
34
+ import { createFragment, createScalarPropMock } from './collectionHelpers.js'
35
+ import { applyCompiledSelection, type CompiledSelection } from './compiledSelection.js'
36
+ import { isValidCompiledSelection } from './compiledSelectionShape.js'
34
37
  import { type Condition, evaluateCondition } from './conditions.js'
35
38
  import { useRefSubscription } from '../hooks/useFields.js'
36
39
 
@@ -53,6 +56,45 @@ export const COMPONENT_BRAND = Symbol('COMPONENT_BRAND')
53
56
  */
54
57
  export const COMPONENT_SELECTIONS = Symbol('COMPONENT_SELECTIONS')
55
58
 
59
+ // ============================================================================
60
+ // Static Selection Validation
61
+ // ============================================================================
62
+
63
+ /**
64
+ * When enabled, components carrying a precompiled static selection ALSO run the
65
+ * runtime proxy pass and warn on any per-prop mismatch. Trust-building mode for
66
+ * dev/CI; off by default so production skips the proxy pass entirely.
67
+ */
68
+ let staticSelectionValidationEnabled = false
69
+
70
+ /**
71
+ * Enables or disables static-selection validate mode (module-level flag).
72
+ */
73
+ export function setStaticSelectionValidation(enabled: boolean): void {
74
+ staticSelectionValidationEnabled = enabled
75
+ }
76
+
77
+ /** Reads the module-level validate-mode flag (used by <Entity>'s root compilation). */
78
+ export function isStaticSelectionValidationEnabled(): boolean {
79
+ return staticSelectionValidationEnabled
80
+ }
81
+
82
+ /** Killswitch: when off, all consumers ignore compiled literals and use the runtime proxy path. */
83
+ let compiledSelectionsEnabled = true
84
+
85
+ /**
86
+ * Disables compiled selections at runtime — incident mitigation without a rebuild.
87
+ * <Entity> roots switch on their next render; a createComponent switches only if not yet collected.
88
+ */
89
+ export function setCompiledSelectionsEnabled(enabled: boolean): void {
90
+ compiledSelectionsEnabled = enabled
91
+ }
92
+
93
+ /** Reads the module-level compiled-selections killswitch (used by both consumers). */
94
+ export function isCompiledSelectionsEnabled(): boolean {
95
+ return compiledSelectionsEnabled
96
+ }
97
+
56
98
  // ============================================================================
57
99
  // Entity Config (Runtime)
58
100
  // ============================================================================
@@ -103,6 +145,8 @@ export function buildComponent<TProps extends object>(
103
145
  conditionFn: ((props: TProps) => Condition) | null,
104
146
  slotNames: readonly string[],
105
147
  useFns: readonly ((props: TProps) => object)[],
148
+ mockValues: Record<string, unknown>,
149
+ compiled: CompiledSelection | null,
106
150
  ): unknown {
107
151
  const selectionsMap = new Map<string, SelectionPropMeta>()
108
152
  const componentDisplayName = `BindxComponent(${[...entityConfigs.keys()].join(', ')})`
@@ -129,11 +173,66 @@ export function buildComponent<TProps extends object>(
129
173
  // Interface props are appended during lazy collection, before the first runtime render.
130
174
  const entityPropNames = [...entityConfigs.keys()]
131
175
 
176
+ // Selector-backed props are the only ones step 1 seeds into selectionsMap; anything
177
+ // else there came from a collection pass and must not survive a reset.
178
+ const explicitEntityPropNames = [...entityConfigs.entries()]
179
+ .filter(([_, c]) => c.selector)
180
+ .map(([name]) => name)
181
+
132
182
  // 2. Implicit entities - collect lazily to avoid TDZ errors
133
183
  const implicitConfigs = [...entityConfigs.entries()].filter(([_, c]) => !c.selector)
134
184
  // Tri-state: 'collecting' terminates self-recursive components
135
185
  let collectionState: 'idle' | 'collecting' | 'done' = 'idle'
136
186
 
187
+ // Drops any implicit/hole-derived entries so a partial compiled pass can't leak into the fallback.
188
+ function resetToExplicitSelections(): void {
189
+ for (const key of [...selectionsMap.keys()]) {
190
+ if (!explicitEntityPropNames.includes(key)) {
191
+ selectionsMap.delete(key)
192
+ }
193
+ }
194
+ }
195
+
196
+ // Applies the compiled literal; returns false (⇒ caller falls back to the proxy pass) on a
197
+ // malformed literal or a top-level throw. Per-hole failures stay contained inside applyCompiledSelection.
198
+ function tryApplyCompiled(): boolean {
199
+ if (!isValidCompiledSelection(compiled, implicitConfigs.map(([name]) => name))) {
200
+ console.warn(
201
+ `[bindx] compiled selection for <${componentDisplayName}> is malformed (version/shape check failed) — `
202
+ + 'falling back to runtime collection.',
203
+ )
204
+ return false
205
+ }
206
+ try {
207
+ applyCompiledSelection({
208
+ compiled,
209
+ selectionsMap,
210
+ componentBrand,
211
+ roles,
212
+ implicitConfigs,
213
+ schemaRegistry,
214
+ componentDisplayName,
215
+ validateMode: staticSelectionValidationEnabled,
216
+ })
217
+ } catch (error) {
218
+ resetToExplicitSelections()
219
+ console.warn(
220
+ `[bindx] compiled selection for <${componentDisplayName}> failed to resolve — `
221
+ + 'falling back to runtime collection.',
222
+ error,
223
+ )
224
+ return false
225
+ }
226
+ if (staticSelectionValidationEnabled) {
227
+ validateCompiledSelection(
228
+ selectionsMap, componentDisplayName,
229
+ implicitConfigs, renderFn, componentBrand, roles,
230
+ hasInterfacesMode, schemaRegistry, conditionFn, mockValues,
231
+ )
232
+ }
233
+ return true
234
+ }
235
+
137
236
  // Runs only from the static-analysis surface (getSelection, $propName fragment
138
237
  // getters) — never from render. Render bodies stay pure runtime code.
139
238
  function ensureImplicitCollected(): void {
@@ -145,7 +244,13 @@ export function buildComponent<TProps extends object>(
145
244
  }
146
245
  collectionState = 'collecting'
147
246
  try {
148
- collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn)
247
+ // Precompiled selection present ⇒ build entries from it, skip the proxy pass.
248
+ // Killswitch off or a malformed/throwing literal falls back to the proxy pass wholesale (never under-fetch).
249
+ // Interfaces mode discovers props during the proxy pass; a literal cannot carry them.
250
+ if (compiled && !hasInterfacesMode && isCompiledSelectionsEnabled() && tryApplyCompiled()) {
251
+ return
252
+ }
253
+ collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn, mockValues)
149
254
  } catch (error) {
150
255
  // Analysis is deterministic, so retrying is pointless — degrade loudly
151
256
  // to the fields captured before the throw (scopes record eagerly).
@@ -291,34 +396,6 @@ function createExplicitPropMock(): unknown {
291
396
  })
292
397
  }
293
398
 
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
-
322
399
  /**
323
400
  * Collects selections from JSX for implicit entity props.
324
401
  *
@@ -334,6 +411,7 @@ function collectImplicitSelections<TProps extends object>(
334
411
  hasInterfacesMode: boolean,
335
412
  schemaRegistry: SchemaRegistry<Record<string, object>> | null,
336
413
  conditionFn: ((props: TProps) => Condition) | null,
414
+ mockValues: Record<string, unknown>,
337
415
  ): void {
338
416
  const propScopes = new Map<string, SelectionScope>()
339
417
  const implicitConfigsMap = new Map(implicitConfigs)
@@ -373,6 +451,12 @@ function collectImplicitSelections<TProps extends object>(
373
451
  return createCollectorProxy(scope, entityName, resolvedRegistry)
374
452
  }
375
453
 
454
+ // Deterministic analysis-time value; must win over the interfaces-mode
455
+ // branch so a mocked scalar is never mistaken for an interface entity prop.
456
+ if (Object.hasOwn(mockValues, propName)) {
457
+ return mockValues[propName]
458
+ }
459
+
376
460
  // In interfaces mode, any unknown prop could be an interface entity prop
377
461
  // Create or reuse a scope for it and return a collector proxy
378
462
  if (hasInterfacesMode) {
@@ -430,26 +514,112 @@ function collectImplicitSelections<TProps extends object>(
430
514
  }
431
515
 
432
516
  // ============================================================================
433
- // Fragment Creation
517
+ // Static Selection Application & Validation
434
518
  // ============================================================================
435
519
 
436
520
  /**
437
- * Creates a FluentFragment from selection metadata.
521
+ * Validate mode: also run the proxy pass (which resolves nested holes by
522
+ * rendering them inline) and warn (once) when the compiled selection
523
+ * under-fetches relative to what runtime collection would produce. Diffs over
524
+ * the runtime props so hole-derived entity props are covered too.
438
525
  */
439
- function createFragment(
440
- selection: SelectionMeta,
526
+ function validateCompiledSelection<TProps extends object>(
527
+ compiledMap: Map<string, SelectionPropMeta>,
528
+ componentDisplayName: string,
529
+ implicitConfigs: [string, EntityConfig][],
530
+ renderFn: (props: TProps) => ReactNode,
441
531
  componentBrand: ComponentBrand,
442
532
  roles: readonly string[],
443
- ): FluentFragment<unknown, object, AnyBrand> {
444
- return {
445
- __meta: selection,
446
- __resultType: {} as object,
447
- __modelType: undefined as unknown,
448
- __isFragment: true,
449
- __brand: componentBrand,
450
- __brands: new Set([componentBrand.brandSymbol]),
451
- __roles: roles.length > 0 ? roles : undefined,
533
+ hasInterfacesMode: boolean,
534
+ schemaRegistry: SchemaRegistry<Record<string, object>> | null,
535
+ conditionFn: ((props: TProps) => Condition) | null,
536
+ mockValues: Record<string, unknown>,
537
+ ): void {
538
+ const runtimeMap = new Map<string, SelectionPropMeta>()
539
+ try {
540
+ collectImplicitSelections(implicitConfigs, renderFn, runtimeMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn, mockValues)
541
+ } catch (error) {
542
+ console.error(
543
+ `[bindx] validate-mode proxy pass of <${componentDisplayName}> crashed — `
544
+ + 'under-fetch diff skipped; the compiled selection still stands.',
545
+ error,
546
+ )
547
+ return
548
+ }
549
+
550
+ const lines: string[] = []
551
+ for (const propName of runtimeMap.keys()) {
552
+ const compiledSelectionMeta = compiledMap.get(propName)?.selection
553
+ const runtimeSelectionMeta = runtimeMap.get(propName)?.selection
554
+ diffUnderfetchedFields(compiledSelectionMeta, runtimeSelectionMeta, [propName], lines)
555
+ }
556
+
557
+ if (lines.length > 0) {
558
+ console.warn(
559
+ `[bindx] static selection under-fetches for <${componentDisplayName}>:\n${lines.join('\n')}`,
560
+ )
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Validate mode for a compiled <Entity> root: warn (once per collection) on fields
566
+ * the runtime children-collector walk requests but the compiled root omits. Reuses
567
+ * the same under-fetch-only diff as createComponent; the warn names the entity type.
568
+ */
569
+ export function validateCompiledRootSelection(
570
+ compiledSelection: SelectionMeta,
571
+ runtimeSelection: SelectionMeta,
572
+ entityType: string,
573
+ ): void {
574
+ const lines: string[] = []
575
+ diffUnderfetchedFields(compiledSelection, runtimeSelection, [entityType], lines)
576
+ if (lines.length > 0) {
577
+ console.warn(
578
+ `[bindx] static selection under-fetches for <Entity ${entityType}>:\n${lines.join('\n')}`,
579
+ )
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Under-fetch diff: warn only for fields the runtime (proxy) selection requests
585
+ * that the static selection omits — the sole mismatch class that is a fetch bug.
586
+ * Fields present only in static (branch unions) and params/alias/isArray-only
587
+ * differences are intentionally NOT reported: the compiler unions all branches
588
+ * and the runtime never records has-many params in implicit collection. Keyed by
589
+ * `fieldName` (not alias) so a params-driven alias never reads as a missing field.
590
+ */
591
+ function diffUnderfetchedFields(
592
+ staticMeta: SelectionMeta | undefined,
593
+ runtimeMeta: SelectionMeta | undefined,
594
+ path: string[],
595
+ lines: string[],
596
+ ): void {
597
+ if (!runtimeMeta) {
598
+ return
599
+ }
600
+ const staticByField = indexByFieldName(staticMeta)
601
+ for (const runtimeField of runtimeMeta.fields.values()) {
602
+ const staticField = staticByField.get(runtimeField.fieldName)
603
+ if (!staticField) {
604
+ lines.push(` missing from static (under-fetch): ${[...path, runtimeField.fieldName].join('.')}`)
605
+ continue
606
+ }
607
+ // Both sides fetch this relation — recurse into what the runtime nests.
608
+ if (runtimeField.nested) {
609
+ diffUnderfetchedFields(staticField.nested, runtimeField.nested, [...path, runtimeField.fieldName], lines)
610
+ }
611
+ }
612
+ }
613
+
614
+ /** Index a selection's top-level fields by field name (aliases collapse). */
615
+ function indexByFieldName(meta: SelectionMeta | undefined): Map<string, SelectionFieldMeta> {
616
+ const byField = new Map<string, SelectionFieldMeta>()
617
+ if (meta) {
618
+ for (const field of meta.fields.values()) {
619
+ byField.set(field.fieldName, field)
620
+ }
452
621
  }
622
+ return byField
453
623
  }
454
624
 
455
625
  // ============================================================================
@@ -2,7 +2,8 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef,
2
2
  import { useBindxContext, useSchemaRegistry } from '../../hooks/BackendAdapterContext.js'
3
3
  import { annotateElement } from '../devAnnotations.js'
4
4
  import { useEntity } from '../../hooks/useEntity.js'
5
- import { useSelectionCollection } from '../../hooks/useSelectionCollection.js'
5
+ import { useRootSelection } from '../../hooks/useRootSelection.js'
6
+ import type { CompiledSelection } from '../compiledSelection.js'
6
7
  import type { EntityAccessor, EntityRef, SelectionMeta } from '../types.js'
7
8
  import { type EntityDef, type EntityUniqueWhere, type AnyBrand, type FieldError, EntityHandle, type SnapshotStore, type ActionDispatcher, type SchemaRegistry, type CommonEntity } from '@contember/bindx'
8
9
 
@@ -19,6 +20,12 @@ interface EntityBaseProps<TRoleMap extends Record<string, object>> {
19
20
  children: (entity: EntityRef<CommonEntity<TRoleMap>>) => React.ReactNode
20
21
  /** Error fallback */
21
22
  error?: (error: FieldError) => React.ReactNode
23
+ /**
24
+ * Precompiled root selection — injected by the bindx compiler, do not hand-write.
25
+ * When present the root selection is built statically (fields under the fixed
26
+ * `entity` key + resolved holes) and `children` is not invoked with a collector.
27
+ */
28
+ compiledSelection?: CompiledSelection
22
29
  }
23
30
 
24
31
  /**
@@ -72,6 +79,7 @@ interface EntityByModeProps {
72
79
  loading?: React.ReactNode
73
80
  error?: (error: FieldError) => React.ReactNode
74
81
  notFound?: React.ReactNode
82
+ compiledSelection?: CompiledSelection
75
83
  }
76
84
 
77
85
  interface EntityCreateModeProps {
@@ -79,6 +87,7 @@ interface EntityCreateModeProps {
79
87
  children: (entity: EntityAccessor<unknown>) => React.ReactNode
80
88
  error?: (error: FieldError) => React.ReactNode
81
89
  onPersisted?: (id: string) => void
90
+ compiledSelection?: CompiledSelection
82
91
  }
83
92
 
84
93
  // ==================== EntityByMode Component ====================
@@ -94,6 +103,7 @@ function EntityByMode({
94
103
  loading,
95
104
  error: errorFallback,
96
105
  notFound,
106
+ compiledSelection,
97
107
  }: EntityByModeProps): ReactElement | null {
98
108
  const { store, dispatcher } = useBindxContext()
99
109
  const schemaRegistry = useSchemaRegistry()
@@ -101,11 +111,13 @@ function EntityByMode({
101
111
  // Stable key for the 'by' clause
102
112
  const byKey = useMemo(() => JSON.stringify(by), [by])
103
113
 
104
- // Phase 1: Collect JSX selection
105
- const { selection, queryKey: selectionQueryKey } = useSelectionCollection({
114
+ // Phase 1: Build the root selection — statically when compiled, else via the
115
+ // children-collector walk.
116
+ const { selection, queryKey: selectionQueryKey } = useRootSelection({
106
117
  entityType,
107
118
  depsKey: byKey,
108
- collect: collector => children(collector as EntityAccessor<unknown>),
119
+ children,
120
+ compiledSelection,
109
121
  })
110
122
 
111
123
  // Combine internal selection key with user-supplied invalidation key so
@@ -181,6 +193,7 @@ function EntityCreateMode({
181
193
  entityType,
182
194
  children,
183
195
  onPersisted,
196
+ compiledSelection,
184
197
  }: EntityCreateModeProps): ReactElement {
185
198
  const { store } = useBindxContext()
186
199
  const tempIdRef = useRef<string | null>(null)
@@ -216,7 +229,7 @@ function EntityCreateMode({
216
229
  }
217
230
 
218
231
  return (
219
- <EntityCreateModeInner entityType={entityType} tempId={tempId} onPersisted={onPersisted}>
232
+ <EntityCreateModeInner entityType={entityType} tempId={tempId} onPersisted={onPersisted} compiledSelection={compiledSelection}>
220
233
  {children}
221
234
  </EntityCreateModeInner>
222
235
  )
@@ -232,11 +245,13 @@ function EntityCreateModeInner({
232
245
  tempId,
233
246
  children,
234
247
  onPersisted,
248
+ compiledSelection,
235
249
  }: {
236
250
  entityType: string
237
251
  tempId: string
238
252
  children: (entity: EntityAccessor<unknown>) => React.ReactNode
239
253
  onPersisted?: (id: string) => void
254
+ compiledSelection?: CompiledSelection
240
255
  }): ReactElement {
241
256
  const { store, dispatcher } = useBindxContext()
242
257
  const schemaRegistry = useSchemaRegistry()
@@ -283,11 +298,13 @@ function EntityCreateModeInner({
283
298
  }
284
299
  }, [persistedId, onPersisted])
285
300
 
286
- // Selection collection still works for building mutations
287
- useSelectionCollection({
301
+ // Selection collection still works for building mutations. A compiled selection
302
+ // skips the children(collector) walk here too.
303
+ useRootSelection({
288
304
  entityType,
289
305
  depsKey: tempId,
290
- collect: collector => children(collector as EntityAccessor<unknown>),
306
+ children,
307
+ compiledSelection,
291
308
  })
292
309
 
293
310
  // Create EntityHandle (no selection for create mode - all fields accessible)
@@ -414,6 +431,7 @@ function EntityImpl<TRoleMap extends Record<string, object>>(
414
431
  children={createProps.children as (entity: EntityAccessor<unknown>) => React.ReactNode}
415
432
  error={createProps.error}
416
433
  onPersisted={createProps.onPersisted}
434
+ compiledSelection={createProps.compiledSelection}
417
435
  />
418
436
  )
419
437
  }
@@ -428,6 +446,7 @@ function EntityImpl<TRoleMap extends Record<string, object>>(
428
446
  loading={byProps.loading}
429
447
  error={byProps.error}
430
448
  notFound={byProps.notFound}
449
+ compiledSelection={byProps.compiledSelection}
431
450
  />
432
451
  )
433
452
  }