@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,111 @@
1
+ import { useEffect, useMemo, useRef, type ReactNode } from 'react'
2
+ import { buildQueryFromSelection } from '@contember/bindx'
3
+ import type { EntityAccessor } from '../jsx/types.js'
4
+ import { useSchemaRegistry } from './BackendAdapterContext.js'
5
+ import { useSelectionCollection, type SelectionCollectionResult } from './useSelectionCollection.js'
6
+ import { COMPILED_ROOT_KEY, resolveCompiledRootSelection, type CompiledSelection } from '../jsx/compiledSelection.js'
7
+ import { isValidCompiledSelection } from '../jsx/compiledSelectionShape.js'
8
+ import { isCompiledSelectionsEnabled, isStaticSelectionValidationEnabled, validateCompiledRootSelection } from '../jsx/componentFactory.js'
9
+
10
+ /**
11
+ * Params for {@link useRootSelection}.
12
+ */
13
+ export interface UseRootSelectionParams {
14
+ readonly entityType: string
15
+ readonly depsKey: string
16
+ readonly children: (entity: EntityAccessor<unknown>) => ReactNode
17
+ /** Compiler-injected root selection. Absent ⇒ the runtime children walk runs. */
18
+ readonly compiledSelection?: CompiledSelection
19
+ }
20
+
21
+ /**
22
+ * Root selection for <Entity>. With a compiled selection the root SelectionMeta is
23
+ * built statically and `children` is NEVER invoked with a collector; without it the
24
+ * runtime children-collector walk runs as before. Validate mode additionally runs
25
+ * the walk (contained) and diffs it against the compiled root for under-fetches.
26
+ */
27
+ export function useRootSelection(params: UseRootSelectionParams): SelectionCollectionResult {
28
+ const { entityType, depsKey, children, compiledSelection } = params
29
+ const schemaRegistry = useSchemaRegistry()
30
+ const validateMode = isStaticSelectionValidationEnabled()
31
+ const compiledEnabled = isCompiledSelectionsEnabled()
32
+ // Same query ⇒ same result object, so consumers keyed on the selection keep their handles.
33
+ const compiledCacheRef = useRef<SelectionCollectionResult | null>(null)
34
+
35
+ // Compiled root selection — present ⇒ skip the children(collector) walk (unless validating).
36
+ // Killswitch off or a malformed/throwing literal yields null ⇒ the runtime walk runs. Decided
37
+ // INSIDE the memo so the hook sequence below stays identical across the compiled/uncompiled path.
38
+ const compiledResult = useMemo((): SelectionCollectionResult | null => {
39
+ if (!compiledSelection || !compiledEnabled) {
40
+ return null
41
+ }
42
+ if (!isValidCompiledSelection(compiledSelection, [COMPILED_ROOT_KEY])) {
43
+ console.warn(
44
+ `[bindx] compiled selection for <Entity ${entityType}> is malformed (version/shape check failed) — `
45
+ + 'falling back to the runtime children walk.',
46
+ )
47
+ return null
48
+ }
49
+ try {
50
+ const selection = resolveCompiledRootSelection({
51
+ compiled: compiledSelection,
52
+ entityType,
53
+ schemaRegistry,
54
+ validateMode,
55
+ })
56
+ const queryKey = JSON.stringify(buildQueryFromSelection(selection))
57
+ const cached = compiledCacheRef.current
58
+ if (cached && cached.queryKey === queryKey) {
59
+ return cached
60
+ }
61
+ const result = { selection, queryKey }
62
+ compiledCacheRef.current = result
63
+ return result
64
+ } catch (error) {
65
+ console.warn(
66
+ `[bindx] compiled selection for <Entity ${entityType}> failed to resolve — `
67
+ + 'falling back to the runtime children walk.',
68
+ error,
69
+ )
70
+ return null
71
+ }
72
+ }, [compiledSelection, compiledEnabled, entityType, schemaRegistry, validateMode])
73
+
74
+ // Runtime walk. Its collect no-ops when compiled & not validating, so children is
75
+ // never called with a collector. In validate mode the walk runs to feed the diff;
76
+ // a crash there is contained so it cannot break the compiled path.
77
+ // The skip is part of the memo key: leaving the compiled path after mount (killswitch,
78
+ // prop dropped) must re-run the walk instead of reusing the empty skipped result.
79
+ const isWalkSkipped = compiledResult !== null && !validateMode
80
+ const runtimeResult = useSelectionCollection({
81
+ entityType,
82
+ depsKey: isWalkSkipped ? `${depsKey}::compiled` : depsKey,
83
+ collect: collector => {
84
+ if (isWalkSkipped) {
85
+ return null
86
+ }
87
+ try {
88
+ return children(collector as EntityAccessor<unknown>)
89
+ } catch (error) {
90
+ if (!compiledResult) {
91
+ throw error
92
+ }
93
+ console.error(
94
+ `[bindx] validate-mode children walk of <Entity ${entityType}> crashed — `
95
+ + 'under-fetch diff skipped; the compiled selection still stands.',
96
+ error,
97
+ )
98
+ return null
99
+ }
100
+ },
101
+ })
102
+
103
+ // Validate mode: warn on fields the runtime walk requests but the compiled root omits.
104
+ useEffect(() => {
105
+ if (compiledResult && validateMode) {
106
+ validateCompiledRootSelection(compiledResult.selection, runtimeResult.selection, entityType)
107
+ }
108
+ }, [compiledResult, runtimeResult, validateMode, entityType])
109
+
110
+ return compiledResult ?? runtimeResult
111
+ }
package/src/index.ts CHANGED
@@ -37,6 +37,9 @@ export type {
37
37
  FluentDefiner,
38
38
  HasManyOptions,
39
39
  InferSelection,
40
+ // Static (precompiled) selection
41
+ StaticFieldMap,
42
+ StaticFieldNode,
40
43
  QuerySpec,
41
44
  QueryFieldSpec,
42
45
  // Query types (typed filter/orderBy)
@@ -274,6 +277,9 @@ export type {
274
277
  BuildProps,
275
278
  BuildFragmentProps,
276
279
  InitialBuilderState,
280
+ // Compiled selection contract (v2)
281
+ CompiledSelection,
282
+ CompiledHole,
277
283
  } from './jsx/index.js'
278
284
 
279
285
  // ============================================================================
@@ -356,6 +362,11 @@ export {
356
362
  // Component (unified API)
357
363
  createComponent,
358
364
  withCollector,
365
+ COLLECTOR_CONTRACT,
366
+ itemOf,
367
+ entityOf,
368
+ type CallbackContract,
369
+ type CollectorContract,
359
370
  isBindxComponent,
360
371
  mergeFragments,
361
372
  COMPONENT_MARKER,
@@ -365,6 +376,8 @@ export {
365
376
  getComponentBrand,
366
377
  setBrandValidation,
367
378
  validateBrand,
379
+ setStaticSelectionValidation,
380
+ setCompiledSelectionsEnabled,
368
381
  } from './jsx/index.js'
369
382
 
370
383
  // Entity Scope
@@ -113,13 +113,21 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
113
113
  }
114
114
  }
115
115
 
116
+ /**
117
+ * Resolves a human-readable name for a component for error/warn attribution,
118
+ * falling back to 'anonymous' when nothing usable is present.
119
+ */
120
+ export function resolveComponentName(component: unknown): string {
121
+ const c = component as { displayName?: string; name?: string; type?: { displayName?: string; name?: string } }
122
+ return c.displayName ?? c.type?.displayName ?? (c.name || c.type?.name) ?? 'anonymous'
123
+ }
124
+
116
125
  /**
117
126
  * Reports a selection-analysis failure of a single component with attribution,
118
127
  * so one broken component doesn't silently cost its siblings their selection.
119
128
  */
120
129
  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'
130
+ const name = resolveComponentName(component)
123
131
  console.error(
124
132
  `[bindx] Selection analysis of <${name}> failed — its fields were NOT added to the fetch plan. `
125
133
  + 'Fix the error below; fields it uses may be missing from queries until then.',
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared building blocks for the selection collection pass — used by both the
3
+ * runtime proxy collector (componentFactory) and compiled-selection/hole
4
+ * resolution (compiledSelection). Extracted to keep a clean module boundary
5
+ * and avoid a cycle between those two.
6
+ */
7
+ import type { FluentFragment, SelectionMeta, AnyBrand } from '@contember/bindx'
8
+ import { ComponentBrand } from '@contember/bindx'
9
+
10
+ /**
11
+ * Creates a FluentFragment from selection metadata.
12
+ */
13
+ export function createFragment(
14
+ selection: SelectionMeta,
15
+ componentBrand: ComponentBrand,
16
+ roles: readonly string[],
17
+ ): FluentFragment<unknown, object, AnyBrand> {
18
+ return {
19
+ __meta: selection,
20
+ __resultType: {} as object,
21
+ __modelType: undefined as unknown,
22
+ __isFragment: true,
23
+ __brand: componentBrand,
24
+ __brands: new Set([componentBrand.brandSymbol]),
25
+ __roles: roles.length > 0 ? roles : undefined,
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Creates a tolerant stand-in for scalar (non-entity) props during collection.
31
+ * Render bodies may call it (`t('key')`), read nested properties
32
+ * (`labels.heading`) or coerce it to a primitive — all are no-ops so the
33
+ * collection pass keeps capturing entity field accesses (see issue #57).
34
+ */
35
+ export function createScalarPropMock(): unknown {
36
+ const mock: unknown = new Proxy(function () {}, {
37
+ get(_target, prop): unknown {
38
+ if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') {
39
+ return (): string => ''
40
+ }
41
+ if (prop === Symbol.iterator) {
42
+ return function* (): Generator<never> {}
43
+ }
44
+ // undefined keeps JSON.stringify from recursing via a callable toJSON
45
+ if (prop === 'toJSON') {
46
+ return undefined
47
+ }
48
+ return mock
49
+ },
50
+ apply(): unknown {
51
+ return mock
52
+ },
53
+ })
54
+ return mock
55
+ }
@@ -0,0 +1,71 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { EntityRef, HasManyRef, HasOneRef } from './types.js'
3
+ import { HasMany } from './components/HasMany.js'
4
+ import { HasOne } from './components/HasOne.js'
5
+
6
+ /**
7
+ * Declares how a callback prop is invoked, so the analyzer can treat it like a HasMany/HasOne child.
8
+ * Source of truth: the build-time compiler imports these types (see
9
+ * packages/bindx-compiler/src/contracts.ts) to parse what this module derives.
10
+ */
11
+ export interface CallbackContract {
12
+ readonly kind: 'itemOf' | 'entityOf'
13
+ readonly field: string
14
+ }
15
+
16
+ /** Maps a callback prop name (`children` included) to the relation it is invoked over. */
17
+ export type CollectorContract = Record<string, CallbackContract>
18
+
19
+ /** Symbol under which a contract is attached to a component for introspection. */
20
+ export const COLLECTOR_CONTRACT: unique symbol = Symbol('bindx.collectorContract')
21
+
22
+ /** Callback prop `field` receives each item of the has-many relation prop `field`. */
23
+ export function itemOf(field: string): CallbackContract {
24
+ return { kind: 'itemOf', field }
25
+ }
26
+
27
+ /** Callback prop `field` receives the entity of the has-one relation prop `field`. */
28
+ export function entityOf(field: string): CallbackContract {
29
+ return { kind: 'entityOf', field }
30
+ }
31
+
32
+ type RelationCallback = (value: EntityRef<unknown>, index?: number) => ReactNode
33
+
34
+ // Collection-boundary narrowing only: props here are collector proxies, so the ref predicates
35
+ // check object-ness, not ref shape; a wrong prop is caught by HasMany/HasOne collection itself.
36
+ function isRelationCallback(value: unknown): value is RelationCallback {
37
+ return typeof value === 'function'
38
+ }
39
+
40
+ function isHasManyRef(value: unknown): value is HasManyRef<unknown> {
41
+ return typeof value === 'object' && value !== null
42
+ }
43
+
44
+ function isHasOneRef(value: unknown): value is HasOneRef<unknown> {
45
+ return typeof value === 'object' && value !== null
46
+ }
47
+
48
+ /**
49
+ * Derives a staticRender from a contract: one `<HasMany>`/`<HasOne>` per entry, replaying
50
+ * the callback prop as its child. A missing callback still registers the relation with no
51
+ * nested selection — mirroring HasMany/HasOne when their children collect nothing.
52
+ */
53
+ export function deriveContractStaticRender(contract: CollectorContract): (props: Record<string, unknown>) => ReactNode {
54
+ const entries = Object.entries(contract)
55
+ return (props: Record<string, unknown>): ReactNode => (
56
+ <>
57
+ {entries.map(([callbackName, { kind, field }]) => {
58
+ const fieldValue = props[field]
59
+ const callbackValue = props[callbackName]
60
+ const child = isRelationCallback(callbackValue) ? callbackValue : (): ReactNode => null
61
+
62
+ if (kind === 'itemOf') {
63
+ if (!isHasManyRef(fieldValue)) return null
64
+ return <HasMany key={callbackName} field={fieldValue}>{(item, index) => child(item, index)}</HasMany>
65
+ }
66
+ if (!isHasOneRef(fieldValue)) return null
67
+ return <HasOne key={callbackName} field={fieldValue}>{entity => child(entity)}</HasOne>
68
+ })}
69
+ </>
70
+ )
71
+ }
@@ -12,7 +12,7 @@ import {
12
12
  FIELD_REF_META,
13
13
  SCOPE_REF,
14
14
  } from './types.js'
15
- import { wrapEntityRefWithFieldAccessProxy } from './proxyShared.js'
15
+ import { wrapEntityRefWithFieldAccessProxy, REACT_ELEMENT_PROBE_KEYS } from './proxyShared.js'
16
16
 
17
17
  /**
18
18
  * Combined ref type for collector that satisfies all accessor interfaces.
@@ -300,6 +300,12 @@ function wrapCollectorRefWithFieldAccessProxy(
300
300
  return Reflect.get(target, prop)
301
301
  }
302
302
 
303
+ // React element probes (e.g. `{article.title}` rendered directly as a
304
+ // child) must not upgrade this scalar ref to a relation.
305
+ if (REACT_ELEMENT_PROBE_KEYS.has(prop)) {
306
+ return Reflect.get(target, prop)
307
+ }
308
+
303
309
  if (isHasOneRelation) {
304
310
  // For has-one relations, match runtime EntityHandle proxy behavior:
305
311
  // Only pass through id, $-prefixed, and __-prefixed properties.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Compiled-selection contract (v2) and its runtime resolution.
3
+ *
4
+ * The selection compiler emits a {@link CompiledSelection} as the 2nd argument
5
+ * of `.render()`. Phase 1 covered per-prop static field maps; phase 2 adds
6
+ * `holes` — statically-emitted references to nested components that received
7
+ * entity-derived values. Holes are resolved here at collection time through the
8
+ * target's existing runtime selection surface (`getSelection` / `staticRender`),
9
+ * the Relay-fragment-spread equivalent, WITHOUT executing the host render body.
10
+ *
11
+ * Phase 2.1 adds {@link CompiledHole.extraProps}: non-entity values (module-scope
12
+ * bindings, render-scope-free closures) lifted into the hole so a target that
13
+ * invokes them during collection stays oracle-equal — see docs/compiler-plan.md.
14
+ */
15
+ import type { ReactNode } from 'react'
16
+ import type { SelectionMeta, StaticSelectionSchema } from '@contember/bindx'
17
+ import { SchemaRegistry, SelectionScope, ComponentBrand, driveSelectionScope } from '@contember/bindx'
18
+ import { SelectionMetaCollector, mergeSelections } from './SelectionMeta.js'
19
+ import type { StaticFieldMap } from '@contember/bindx'
20
+ import { createCollectorProxy } from './proxy.js'
21
+ import { collectSelection, resolveComponentName } from './analyzer.js'
22
+ import { createFragment, createScalarPropMock } from './collectionHelpers.js'
23
+ import type { SelectionPropMeta } from './componentBuilder.types.js'
24
+ import type { SelectionProvider } from './types.js'
25
+ import type { EntityConfig } from './componentFactory.js'
26
+
27
+ // ============================================================================
28
+ // Contract v2
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Precompiled selection injected by the selection compiler. Breaking-change
33
+ * shape within the experiment — hand-write only in tests that simulate emit.
34
+ */
35
+ export interface CompiledSelection {
36
+ /** Contract version marker — the runtime falls back to the proxy pass unless this is exactly 2. */
37
+ v: 2
38
+ /** Per implicit entity prop — same {@link StaticFieldMap} as phase 1. */
39
+ props: Record<string, StaticFieldMap>
40
+ /** Nested components that received entity-derived values. */
41
+ holes?: CompiledHole[]
42
+ }
43
+
44
+ /**
45
+ * A nested component composition the compiler could not inline statically.
46
+ * Resolved at collection time through the target's selection surface.
47
+ */
48
+ export interface CompiledHole {
49
+ /**
50
+ * Thunk, not a direct reference — dodges TDZ for components defined later in
51
+ * the module (same reason runtime collection is lazy).
52
+ */
53
+ component: () => unknown
54
+ /**
55
+ * Target prop name → where its value comes from: a host entity prop plus a
56
+ * member path. Empty path = the root entity prop itself.
57
+ */
58
+ entityProps: Record<string, { source: string; path: readonly string[] }>
59
+ /**
60
+ * Statically-literal non-entity props of the JSX element. Non-literal
61
+ * non-entity props are simply omitted.
62
+ */
63
+ literalProps?: Record<string, unknown>
64
+ /**
65
+ * Non-entity props lifted verbatim from the emit site: module-scope values
66
+ * (imports / top-level bindings) and render-scope-free inline closures. Each
67
+ * is a thunk (TDZ-safe, same reason as {@link component}) resolved to the real
68
+ * value at collection time, so a target's `staticRender` that *invokes* one
69
+ * (e.g. `props.children(entity)`) collects the same fields the oracle would.
70
+ */
71
+ extraProps?: Record<string, () => unknown>
72
+ }
73
+
74
+ // ============================================================================
75
+ // Runtime resolution
76
+ // ============================================================================
77
+
78
+ export interface ApplyCompiledSelectionParams {
79
+ readonly compiled: CompiledSelection
80
+ readonly selectionsMap: Map<string, SelectionPropMeta>
81
+ readonly componentBrand: ComponentBrand
82
+ readonly roles: readonly string[]
83
+ readonly implicitConfigs: [string, EntityConfig][]
84
+ readonly schemaRegistry: SchemaRegistry<Record<string, object>> | null
85
+ readonly componentDisplayName: string
86
+ readonly validateMode: boolean
87
+ }
88
+
89
+ /**
90
+ * Builds `selectionsMap` entries from a compiled selection — resolving the
91
+ * per-prop SelectionMeta (fields + holes) then wrapping each in a fragment.
92
+ * Replaces the proxy pass entirely; the host render function is never executed.
93
+ */
94
+ export function applyCompiledSelection(params: ApplyCompiledSelectionParams): void {
95
+ const { selectionsMap, componentBrand, roles } = params
96
+ // Like the proxy pass, a component keeps only what reached its scopes (returned fields are dropped).
97
+ const { metas } = resolveCompiledSelection({
98
+ compiled: params.compiled,
99
+ implicitConfigs: params.implicitConfigs,
100
+ schemaRegistry: params.schemaRegistry,
101
+ componentDisplayName: params.componentDisplayName,
102
+ validateMode: params.validateMode,
103
+ })
104
+ for (const [propName, selection] of metas) {
105
+ selectionsMap.set(propName, {
106
+ selection,
107
+ fragment: createFragment(selection, componentBrand, roles),
108
+ })
109
+ }
110
+ }
111
+
112
+ interface ResolveCompiledSelectionParams {
113
+ readonly compiled: CompiledSelection
114
+ readonly implicitConfigs: readonly [string, EntityConfig][]
115
+ readonly schemaRegistry: SchemaRegistry<Record<string, object>> | null
116
+ readonly componentDisplayName: string
117
+ readonly validateMode: boolean
118
+ }
119
+
120
+ interface ResolvedCompiledSelection {
121
+ /** Snapshotted selection per entity prop that captured fields. */
122
+ readonly metas: Map<string, SelectionMeta>
123
+ /** What hole targets returned instead of writing into a scope (kept by the <Entity> root walk only). */
124
+ readonly returned: SelectionMeta
125
+ }
126
+
127
+ /**
128
+ * Core resolution shared by createComponent (fragments) and <Entity> (root
129
+ * selection): seeds one live scope per entity prop from the static field maps,
130
+ * resolves every hole into the matching scope, and snapshots them. The host render fn
131
+ * is never run. Throws on a broken literal (missing hole component, throwing thunk) so
132
+ * the caller falls back to the proxy pass instead of under-fetching.
133
+ */
134
+ function resolveCompiledSelection(params: ResolveCompiledSelectionParams): ResolvedCompiledSelection {
135
+ const { compiled } = params
136
+ const propScopes = new Map<string, SelectionScope>()
137
+
138
+ const scopeFor = (propName: string): SelectionScope => {
139
+ let scope = propScopes.get(propName)
140
+ if (!scope) {
141
+ scope = new SelectionScope()
142
+ propScopes.set(propName, scope)
143
+ }
144
+ return scope
145
+ }
146
+
147
+ const ctx: HoleResolutionContext = {
148
+ scopeFor,
149
+ implicitConfigsMap: new Map(params.implicitConfigs),
150
+ schemaRegistry: params.schemaRegistry,
151
+ componentDisplayName: params.componentDisplayName,
152
+ validateMode: params.validateMode,
153
+ }
154
+
155
+ // 1. Seed a live (open) scope per entity prop from the static field maps.
156
+ for (const [propName, fieldMap] of Object.entries(compiled.props)) {
157
+ driveSelectionScope(scopeFor(propName), fieldMap, schemaFor(propName, ctx))
158
+ }
159
+
160
+ // 2. Resolve each hole into the same scopes (fragment-spread equivalent).
161
+ const returned = new SelectionMetaCollector()
162
+ for (const hole of compiled.holes ?? []) {
163
+ resolveHole(hole, ctx, returned)
164
+ }
165
+
166
+ // 3. Snapshot every scope that captured fields.
167
+ const metas = new Map<string, SelectionMeta>()
168
+ for (const [propName, scope] of propScopes) {
169
+ if (scope.hasFields()) {
170
+ metas.set(propName, scope.toSelectionMeta())
171
+ }
172
+ }
173
+ return { metas, returned }
174
+ }
175
+
176
+ /** Fixed key under which <Entity> stores its compiled root field map + hole sources. */
177
+ export const COMPILED_ROOT_KEY = 'entity'
178
+
179
+ export interface ResolveCompiledRootSelectionParams {
180
+ readonly compiled: CompiledSelection
181
+ readonly entityType: string
182
+ readonly schemaRegistry: SchemaRegistry<Record<string, object>> | null
183
+ readonly validateMode: boolean
184
+ }
185
+
186
+ /**
187
+ * Builds the single root SelectionMeta for a compiled <Entity>: fields under the
188
+ * fixed `entity` key plus every hole (all rooted at `entity`). No fragments —
189
+ * <Entity> is a selection root, not a composable component.
190
+ */
191
+ export function resolveCompiledRootSelection(params: ResolveCompiledRootSelectionParams): SelectionMeta {
192
+ const { metas, returned } = resolveCompiledSelection({
193
+ compiled: params.compiled,
194
+ implicitConfigs: [[COMPILED_ROOT_KEY, { entityName: params.entityType }]],
195
+ schemaRegistry: params.schemaRegistry,
196
+ componentDisplayName: `Entity(${params.entityType})`,
197
+ validateMode: params.validateMode,
198
+ })
199
+ const selection = metas.get(COMPILED_ROOT_KEY) ?? { fields: new Map() }
200
+ // The runtime root walk merges what its JSX components return (e.g. an uploader's nested fields).
201
+ mergeSelections(selection, returned)
202
+ return selection
203
+ }
204
+
205
+ interface HoleResolutionContext {
206
+ readonly scopeFor: (propName: string) => SelectionScope
207
+ readonly implicitConfigsMap: Map<string, EntityConfig>
208
+ readonly schemaRegistry: SchemaRegistry<Record<string, object>> | null
209
+ readonly componentDisplayName: string
210
+ readonly validateMode: boolean
211
+ }
212
+
213
+ /**
214
+ * Resolves one hole: assembles the target's props (entity values replayed from
215
+ * the source scopes + literal props) and feeds them to its selection surface,
216
+ * mirroring `analyzeJsx` order. A broken literal throws; an error inside the
217
+ * target's own surface is contained, as in the proxy pass.
218
+ */
219
+ function resolveHole(hole: CompiledHole, ctx: HoleResolutionContext, returned: SelectionMetaCollector): void {
220
+ const target = hole.component()
221
+ if (target === null || (typeof target !== 'object' && typeof target !== 'function')) {
222
+ throw new Error(`hole component thunk returned ${String(target)} (stale compiled selection?)`)
223
+ }
224
+ const props = assembleHoleProps(hole, ctx)
225
+
226
+ try {
227
+ if (hasGetSelection(target)) {
228
+ // Entity values carry SCOPE_REF, so most targets merge into the source scopes as a side
229
+ // effect; some only return their fields.
230
+ addReturnedFields(returned, target.getSelection(props, collectNested))
231
+ } else if (hasStaticRender(target)) {
232
+ const jsx = target.staticRender(wrapPropsWithScalarFallback(props))
233
+ if (jsx !== null && jsx !== undefined) {
234
+ mergeSelections(returned, collectSelection(jsx))
235
+ }
236
+ } else if (ctx.validateMode) {
237
+ // Plain React target: the runtime proxy pass is equally blind here, so
238
+ // compiled behavior stays equivalent — surface the blind spot in dev/CI.
239
+ console.warn(
240
+ `[bindx] <${resolveComponentName(target)}> inside <${ctx.componentDisplayName}> exposes no `
241
+ + 'selection surface (getSelection/staticRender) — its fields are a selection blind spot.',
242
+ )
243
+ }
244
+ } catch (error) {
245
+ reportHoleError(target, ctx.componentDisplayName, error)
246
+ }
247
+ }
248
+
249
+ /** Adds a `getSelection` result the way `analyzeJsx` does. */
250
+ function addReturnedFields(returned: SelectionMetaCollector, result: ReturnType<SelectionProvider['getSelection']>): void {
251
+ if (!result) {
252
+ return
253
+ }
254
+ for (const field of Array.isArray(result) ? result : [result]) {
255
+ returned.addField(field)
256
+ }
257
+ }
258
+
259
+ /** Real nested-collection walk for `getSelection` targets. */
260
+ function collectNested(node: ReactNode): SelectionMeta {
261
+ return collectSelection(node)
262
+ }
263
+
264
+ /**
265
+ * Assembles the target's props: each entity prop is the source prop's collector
266
+ * proxy with the member path replayed via property gets; literal props merge in.
267
+ */
268
+ function assembleHoleProps(hole: CompiledHole, ctx: HoleResolutionContext): Record<string, unknown> {
269
+ const props: Record<string, unknown> = { ...hole.literalProps }
270
+ // Lifted values reach the target before entity proxies (which must win on collision).
271
+ for (const [prop, thunk] of Object.entries(hole.extraProps ?? {})) {
272
+ props[prop] = thunk()
273
+ }
274
+ for (const [targetProp, origin] of Object.entries(hole.entityProps)) {
275
+ const proxy = createSourceProxy(origin.source, ctx)
276
+ props[targetProp] = replayPath(proxy, origin.path)
277
+ }
278
+ return props
279
+ }
280
+
281
+ /**
282
+ * Creates the collector proxy for a source entity prop, using the same scope +
283
+ * entity-name + schema config that the proxy pass uses for that prop.
284
+ */
285
+ function createSourceProxy(sourceProp: string, ctx: HoleResolutionContext): unknown {
286
+ const { entityName, schemaRegistry } = schemaFor(sourceProp, ctx)
287
+ return createCollectorProxy(ctx.scopeFor(sourceProp), entityName, schemaRegistry)
288
+ }
289
+
290
+ /** The entity name + schema the proxy pass would collect an entity prop with. */
291
+ function schemaFor(propName: string, ctx: HoleResolutionContext): StaticSelectionSchema {
292
+ const config = ctx.implicitConfigsMap.get(propName)
293
+ return {
294
+ entityName: config?.entityName ?? null,
295
+ schemaRegistry: config?.schema ? new SchemaRegistry(config.schema) : ctx.schemaRegistry,
296
+ }
297
+ }
298
+
299
+ /** Replays a member path via property gets — identical to the proxy pass. */
300
+ function replayPath(root: unknown, path: readonly string[]): unknown {
301
+ let current = root
302
+ for (const key of path) {
303
+ if (current === null || (typeof current !== 'object' && typeof current !== 'function')) {
304
+ return current
305
+ }
306
+ current = Reflect.get(current, key)
307
+ }
308
+ return current
309
+ }
310
+
311
+ /**
312
+ * Wraps a staticRender target's props so unknown keys fall back to the tolerant
313
+ * scalar mock (holes carry no children/render-prop closures).
314
+ */
315
+ function wrapPropsWithScalarFallback(props: Record<string, unknown>): Record<string, unknown> {
316
+ return new Proxy(props, {
317
+ get(target, key): unknown {
318
+ if (typeof key === 'symbol' || Object.hasOwn(target, key)) {
319
+ return Reflect.get(target, key)
320
+ }
321
+ return createScalarPropMock()
322
+ },
323
+ })
324
+ }
325
+
326
+ interface GetSelectionSurface {
327
+ getSelection(props: Record<string, unknown>, collectNested: (node: ReactNode) => SelectionMeta): ReturnType<SelectionProvider['getSelection']>
328
+ }
329
+
330
+ interface StaticRenderSurface {
331
+ staticRender(props: Record<string, unknown>): ReactNode
332
+ }
333
+
334
+ function hasGetSelection(target: object): target is GetSelectionSurface {
335
+ return 'getSelection' in target && typeof target.getSelection === 'function'
336
+ }
337
+
338
+ function hasStaticRender(target: object): target is StaticRenderSurface {
339
+ return 'staticRender' in target && typeof target.staticRender === 'function'
340
+ }
341
+
342
+ /**
343
+ * Reports a hole-resolution failure with attribution, matching the
344
+ * report-and-continue policy of `analyzeJsx`'s per-component error handling.
345
+ */
346
+ function reportHoleError(target: unknown, hostName: string, error: unknown): void {
347
+ const name = resolveComponentName(target)
348
+ console.error(
349
+ `[bindx] Hole resolution of <${name}> inside <${hostName}> failed — its fields were NOT added to the fetch plan. `
350
+ + 'Fix the error below; fields it uses may be missing from queries until then.',
351
+ error,
352
+ )
353
+ }