@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.
- package/dist/hooks/useRootSelection.d.ts +22 -0
- package/dist/hooks/useRootSelection.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/jsx/analyzer.d.ts +5 -0
- package/dist/jsx/analyzer.d.ts.map +1 -1
- package/dist/jsx/collectionHelpers.d.ts +20 -0
- package/dist/jsx/collectionHelpers.d.ts.map +1 -0
- package/dist/jsx/collectorContract.d.ts +25 -0
- package/dist/jsx/collectorContract.d.ts.map +1 -0
- package/dist/jsx/compiledSelection.d.ts +80 -0
- package/dist/jsx/compiledSelection.d.ts.map +1 -0
- package/dist/jsx/compiledSelectionShape.d.ts +12 -0
- package/dist/jsx/compiledSelectionShape.d.ts.map +1 -0
- package/dist/jsx/componentBuilder.d.ts +5 -2
- package/dist/jsx/componentBuilder.d.ts.map +1 -1
- package/dist/jsx/componentBuilder.types.d.ts +32 -1
- package/dist/jsx/componentBuilder.types.d.ts.map +1 -1
- package/dist/jsx/componentFactory.d.ts +22 -2
- package/dist/jsx/componentFactory.d.ts.map +1 -1
- package/dist/jsx/components/Entity.d.ts +7 -0
- package/dist/jsx/components/Entity.d.ts.map +1 -1
- package/dist/jsx/index.d.ts +4 -0
- package/dist/jsx/index.d.ts.map +1 -1
- package/dist/jsx/proxyShared.d.ts +8 -0
- package/dist/jsx/proxyShared.d.ts.map +1 -1
- package/dist/jsx/withCollector.d.ts +7 -8
- package/dist/jsx/withCollector.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/hooks/useRootSelection.ts +111 -0
- package/src/index.ts +13 -0
- package/src/jsx/analyzer.ts +10 -2
- package/src/jsx/collectionHelpers.ts +55 -0
- package/src/jsx/collectorContract.tsx +71 -0
- package/src/jsx/collectorProxy.ts +7 -1
- package/src/jsx/compiledSelection.ts +353 -0
- package/src/jsx/compiledSelectionShape.ts +86 -0
- package/src/jsx/componentBuilder.ts +28 -1
- package/src/jsx/componentBuilder.types.ts +38 -1
- package/src/jsx/componentFactory.ts +212 -42
- package/src/jsx/components/Entity.tsx +27 -8
- package/src/jsx/index.ts +18 -0
- package/src/jsx/proxyShared.ts +14 -0
- 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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
517
|
+
// Static Selection Application & Validation
|
|
434
518
|
// ============================================================================
|
|
435
519
|
|
|
436
520
|
/**
|
|
437
|
-
*
|
|
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
|
|
440
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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 {
|
|
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:
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|