Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4b87962
feat(bindx-react): createComponent().mock() for analysis-time prop mocks
matej21 Jul 21, 2026
85b68d7
docs: selection compiler experiment plan
matej21 Jul 21, 2026
7369580
feat(bindx-react): runtime support for precompiled static selections
matej21 Jul 21, 2026
0fcb900
feat(bindx-compiler): static selection analyzer + babel plugin (exper…
matej21 Jul 21, 2026
abc0eab
fix(bindx-react): skip React element probe keys in collector proxy
matej21 Jul 21, 2026
9910807
feat(bindx-react): validate mode warns only on under-fetch
matej21 Jul 21, 2026
b47ff87
feat(bindx-compiler): end-to-end integration, playground wiring, bail…
matej21 Jul 21, 2026
3a51e84
docs: phase 2 plan — nested-component composition holes
matej21 Jul 21, 2026
696c76e
feat(bindx-react): compiled selection v2 — nested-component hole reso…
matej21 Jul 21, 2026
e4afd95
feat(bindx-compiler): emit compiled selection v2 with nested-componen…
matej21 Jul 21, 2026
b1b3b7f
fix(bindx-dataview): walk relation-column renderer JSX for nested sel…
matej21 Jul 21, 2026
31914b7
feat(bindx-compiler): enable hole equivalence + e2e against runtime r…
matej21 Jul 21, 2026
4cfdcaf
fix(bindx-compiler): bail on unsafe function props on hole elements (…
matej21 Jul 21, 2026
cceaf6b
feat(bindx-react): CompiledHole.extraProps for lifted hole values
matej21 Jul 21, 2026
0dcf566
feat(bindx-compiler): lift identifier/closure/cond/JSX hole props via…
matej21 Jul 21, 2026
caba4fa
docs: phase 2.2 plan — collector contracts
matej21 Jul 21, 2026
ab445eb
feat(bindx-react): declarative collector contracts for withCollector
matej21 Jul 21, 2026
1cd5cee
feat(bindx-compiler): collector-contract discovery and contract-aware…
matej21 Jul 21, 2026
8f71e4e
docs: phase 3 plan — Entity root compilation
matej21 Jul 21, 2026
db85777
feat(bindx-react): Entity accepts compiled root selection
matej21 Jul 21, 2026
9676510
feat(bindx-compiler): compile Entity root selections
matej21 Jul 21, 2026
6166dc3
docs: phase 3.1 plan — hole-target classification + entity-like roots
matej21 Jul 22, 2026
7a1fa1d
feat(bindx-compiler): hole-target classification + entityLike roots
matej21 Jul 22, 2026
44f0c46
feat(bindx-compiler): follow re-export chains in module binding resol…
matej21 Jul 22, 2026
1179088
feat(bindx-compiler): vite plugin with cross-module watch invalidation
matej21 Jul 22, 2026
26538c7
feat(bindx-compiler): version-mark emitted selections
matej21 Jul 22, 2026
845ce28
feat(bindx-react): compiled-selection validation, runtime fallback + …
matej21 Jul 22, 2026
89e6c80
feat(bindx-compiler): crash containment, diagnostics option, emit har…
matej21 Jul 22, 2026
8507750
test(bindx-compiler): crash containment, diagnostics, and emit-intero…
matej21 Jul 22, 2026
848c86e
refactor(bindx-compiler): remove casts, unify AST walkers, trim publi…
matej21 Jul 22, 2026
015a9d3
test(bindx-compiler): options plumbing and render-arg coverage
matej21 Jul 22, 2026
72ebd7e
docs: refer to the reference app generically
matej21 Jul 22, 2026
16b1b6f
test(browser): wait for the filtered option before clicking select po…
matej21 Jul 22, 2026
1e847cc
test(browser): retry lost popover clicks via clickUntil helper
matej21 Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

849 changes: 849 additions & 0 deletions docs/compiler-plan.md

Large diffs are not rendered by default.

199 changes: 199 additions & 0 deletions docs/selection-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,209 @@ const SelectField = withCollector(
)
```

### Collector contracts — declare invocation, skip the hand-written staticRender

When a `withCollector` component invokes a callback prop over a relation prop, you can declare that
contract instead of hand-writing a `staticRender`. Declare it once — it drives **both** runtime
collection (the staticRender is derived automatically) **and** the compiler (which then treats the
callback exactly like a `<HasMany>`/`<HasOne>` child, so no build-time hole or lift is needed):

```tsx
import { itemOf, entityOf, withCollector } from '@contember/bindx-react'

// `children` is invoked with each item of the has-many `field`.
export const Repeater = withCollector(
function RepeaterRuntime({ field, children }) { /* … render … */ },
{ children: itemOf('field') }, // vs. entityOf('field') for a has-one callback
)
```

The contract is `Record<callbackPropName, itemOf(field) | entityOf(field)>` (`children` allowed as a
key), also exposed on the component under the `COLLECTOR_CONTRACT` symbol for introspection. This
solves the case a hole cannot: a callback that both uses its item param **and** captures a host-root
field (e.g. `{item => <Row item={item} parentColumns={footer.linkColumns} />}`) — under a contract the
host capture is an ordinary root path, and non-contract function props on the element are dropped
safely (the derived staticRender never invokes them). The compiler discovers contracts declared
locally or imported via a **relative** (or aliased) specifier, following `from`-source re-export
chains through barrel index files (depth-limited, cycle-guarded) — see docs/compiler-plan.md, Phase 2.2.

### `getSelection` — for framework primitives

Low-level API for precise control over reported fields. Used by Field, HasOne, HasMany, Attribute.

## Compiled selections (experimental)

By default, an implicit `createComponent()` discovers its selection at runtime by
executing the render body against collector proxies. The `@contember/bindx-compiler`
Babel plugin can instead prove that selection at build time and emit it as the 2nd
argument of `.render(fn, compiledSelection)`. When present, the runtime uses it directly
and **skips the proxy pass entirely** — no user code runs during collection, so the
crash-and-degrade machinery becomes irrelevant.

The emitted object has the shape `{ props, holes? }`: `props` is the per-entity-prop
static field map; `holes` describe nested components that received entity-derived values
(see below).

This is progressive enhancement: a compiled app behaves identically to an uncompiled
one. It is never mandatory.

### Nested components (holes)

A host render body often passes an entity-derived value to another component:
`<AuthorCard author={article.author} />`. The compiler cannot inline that component's
selection (it lives in another module, may be defined later, etc.), so it emits a
**hole**: a thunk to the target plus a map of which prop comes from which host entity
prop and member path. At collection time the runtime resolves each hole through the
target's own selection surface — `getSelection` (createComponent) or `staticRender`
(`withCollector`) — replaying the member path on collector proxies. This is the
Relay-fragment-spread equivalent: the target's fields are folded into the host's fetch
**without executing the host render body**.

Because both the runtime proxy pass and the compiled path drive the *same* collector
proxies for the escaping value, a hole and a runtime-collected escape produce identical
selections.

**Shared blind spot — plain components.** If the target is a plain React component (no
`getSelection`/`staticRender`), neither the compiler nor the runtime proxy pass can see
the fields it reads (e.g. via `useField`). This is a blind spot on *both* paths, so
compiled and uncompiled behavior stay equivalent. In validate mode the runtime emits a
dev-only warning naming the component so the blind spot is discoverable rather than
silent. The fix is to give the component a selection surface (wrap it with
`withCollector`) or mount sibling `<Field>`s — not a compiler-only change (that would
make the compiled app fetch more than the uncompiled one, breaking equivalence).

### Enabling in Vite

Wire the plugin into `@vitejs/plugin-react`'s `babel.plugins`, behind an env flag:

```ts
import react from '@vitejs/plugin-react'
import { bindxCompilerPlugin } from '@contember/bindx-compiler'

const compilerEnabled = process.env.BINDX_COMPILER === '1'

export default defineConfig({
plugins: [
react(compilerEnabled ? { babel: { plugins: [bindxCompilerPlugin] } } : undefined),
],
})
```

Run with `BINDX_COMPILER=1`. The plugin only injects arguments; it never imports
from bindx-react.

### Validate mode

`setStaticSelectionValidation(true)` (call in your dev entry) makes the runtime ALSO
run the proxy pass alongside the compiled selection and warn on any **under-fetch** —
a field the runtime would fetch that the compiled selection omits. It intentionally
does not warn on the two legitimate divergences where the compiler is more precise:
branch unions (the compiler unions all conditional branches) and has-many
params/many-ness (the runtime never records these in implicit collection).

### Emit-or-bail

The compiler emits a selection only when it can prove it. Over-approximation (extra
fields) is acceptable; under-approximation is impossible by construction (default
deny). Anything it cannot classify makes the whole component **bail** with a
machine-readable reason (e.g. `ENTITY_ESCAPES_TO_CALL`, `ENTITY_IN_EXPRESSION_PROP`,
`FUNCTION_PROP_ON_HOLE`, `RENDER_LOCAL_ON_HOLE`, `ENTITY_REASSIGNMENT`, `ENTITY_SPREAD`,
`COMPUTED_MEMBER`, `MEMBER_COMPONENT_TAG`, `NON_LITERAL_HASMANY_PARAM`, `INTERFACES_MODE`,
`UNCLASSIFIED`). Bailed chains are left untouched and fall back to the runtime proxy pass.

`FUNCTION_PROP_ON_HOLE` / `RENDER_LOCAL_ON_HOLE` guard an under-fetch class: a hole element's
function props / render-prop children / identifier-valued props are non-entity, but a hole target's
`staticRender` may *invoke* them with a collector proxy during collection (the reference app's `SelectField` does
`<HasOne field={props.field}>{e => props.children(e)}</HasOne>`), collecting fields the compiled path
would otherwise miss. **Phase 2.1** resolves most of these by *lifting* the value into the hole's
`extraProps` instead of dropping it: module-scope bindings and render-scope-free closures are in scope
at the emit site, so the real value reaches the target at resolution (`it => <Field field={it.name}/>`
lifts; `onClick={() => save()}` drops as inert; a closure capturing `t` from `.use()` or an entity
root bails, as does a render-local const passed onward). See docs/compiler-plan.md, phase 2.1.

Measure the compiled-vs-bailed rate (and hole counts) over a source tree with
`bun run packages/bindx-compiler/scripts/measure.ts <dir>` (default
`packages/example`). On the largest real bindx app (the reference app, 257 chains)
phase 2.1 compiles **254/257 (99%)** — 38 chains carry 112 holes total — leaving 3 genuine bails
(1 `ENTITY_IN_EXPRESSION_PROP`, 1 `FUNCTION_PROP_ON_HOLE`, 1 `ENTITY_REASSIGNMENT`); phase 2 with
holes compiled 94%, phase 1 without holes 84%.

### Entity roots (phase 3)

Selection ROOTS compile too. A `<Entity>` invokes its children render-prop with a
collector proxy on every root mount — the same crash-prone, one-branch execution the
compiler eliminated for components. The plugin scans for `<Entity>` elements (tag resolving
to a `@contember/bindx*` import) **anywhere** in a file — plain route components, inside
`createComponent` render bodies, at any nesting — and injects a JSX attribute
`compiledSelection={{ props: { entity: {...} }, holes: [...] }}`. The root's field map lives
under the fixed key `entity`; every hole is rooted at `entity`. When present the runtime
builds the root `SelectionMeta` statically and never calls `children` with a collector
(`useRootSelection`); validate mode still runs the contained walk for the under-fetch diff.

The children closure's **first param is the root entity accessor itself** (unlike a
`.render()` body, whose param is the props object) — so it is analyzed exactly like a
`<HasOne>`/`<HasMany>` children callback. All existing machinery applies unchanged: member
paths, holes + `extraProps`, collector contracts, `cond.*` in props, JSX-valued props,
branch union. `<Entity>`'s own props (`entity`, `by`, `filter`, `create`, `onPersisted`,
`queryKey`, `loading`, `error`, `notFound`, …) carry no selection and are never analyzed
(`entity` receives an `entityDef` — a module value, not an entity-rooted value). Non-function
or absent children bail `ENTITY_NO_FUNCTION_CHILDREN`; the runtime walk stays. Each `<Entity>`
element is its own emit-or-bail unit, reported separately by `measure`
(`entity roots: N compiled / M bailed`).

An `<Entity>` nested inside a `createComponent` body is analyzed twice, independently and
soundly: the HOST chain's full-body walk records any host-root captures inside the closure
(the closure param **shadows** host roots), while the Entity's own emit contains ONLY paths
rooted at its closure param. The runtime host walk cannot see into the Entity closure at all,
so the compiler is a sound superset here.

The root oracle is the `QuerySpec` the adapter receives: rendering a transformed vs
untransformed `<Entity>` under a query-recording `MockAdapter` requests the identical root
selection (superset for branch unions). On the reference app, the plugin compiles
**84/105** `<Entity>` roots (114 holes; the reference app's dominant pattern is
`<Entity>{e => <Body entity={e} />}</Entity>`, one delegated hole per root). The 21 bails are
11 `RENDER_LOCAL_ON_HOLE`, 7 `ENTITY_ESCAPES_TO_CALL`, 2 `ENTITY_NO_FUNCTION_CHILDREN`,
1 `FUNCTION_PROP_ON_HOLE` — the same reason classes as chains, since the same machinery runs.
DataGrid/DataView roots are out of scope (different walker; phase 3.1).

### Hole-target classification + `entityLike` roots (phase 3.1)

**createComponent / plain targets no longer bail on render-locals.** A hole element's non-entity
props (identifier render-locals, call results, function props / render-prop children) only risk
under-fetch when the *target* actually invokes/reads them. The compiler now classifies the target
tag — reusing the contract parse cache — into `createComponent`, `plain`, `collectorStatic`, or
`unknown`:

- **`createComponent`** target — `getSelection` never reads scalar props and never invokes
function slots (the slot walk ignores functions), so render-locals and function props/children
drop with **no bail**; the entity props still form the hole. Slot names come from `.slots([...])`
(default `['children']`); JSX-valued props are analyzed statically as before.
- **`plain`** function/class component — no selection surface, so **every** non-entity prop drops;
the hole is still emitted (matching runtime blindness; keeps the validate-mode blind-spot warn).
- **`collectorStatic`** (`withCollector(runtime, staticRenderFn)`) — the compiler reads the *set of
prop names the staticRender references*. A dropped prop **not** in that set is safe; a referenced
render-local still bails. A staticRender that spreads/aliases its props object (`{...props}`,
rest param, `f(props)`) is treated as "references everything" (conservative).
- **`unknown`** (unresolvable, or resolvable via a non-relative unaliased import) — the existing
conservative taint lattice stands (default deny). Collector **contracts** are still handled by
their own resolver, before target-kind classification.

**`entityLike` — roots behind forwarding wrappers.** Some apps wrap `<Entity>` in a thin component
that forwards props (the reference app's `RefreshableEntity` = `withCollector(props => <Entity queryKey={…}
{...props} />, props => <Entity {...props} />)`). Pass `entityLike: ['RefreshableEntity', …]` to
the analyzer/plugin (or `--entity-like=Name,…` to `measure`) and those tags are scanned + emitted
**exactly like `<Entity>`**: the `compiledSelection` attribute is injected on the *wrapper* element
and reaches the inner `<Entity>` through the wrapper's `{...props}` spread — **that props
forwarding is the opt-in requirement** (there is no runtime change; `<Entity>` already consumes
`compiledSelection`). Matching prefers an import's original exported name over its local alias; a
locally-declared wrapper matches by its declared name; default/namespace imports are skipped.

On the reference app: chains stay **254/257**; entity roots go **84 → 93/105** from
classification alone, and **`--entity-like=RefreshableEntity`** surfaces **35 previously-hidden
roots** (105 → 140, 124 compiled).

See [docs/compiler-plan.md](./compiler-plan.md) for the full design.

## Provider Setup

### `BindxProvider` — generic
Expand Down
39 changes: 39 additions & 0 deletions packages/bindx-compiler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@contember/bindx-compiler",
"version": "0.1.46",
"private": true,
"description": "Experimental build-time analyzer + Babel plugin for bindx implicit selections",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --build",
"typecheck": "tsc --build",
"test": "bun test tests/",
"measure": "bun run scripts/measure.ts"
},
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.0"
},
"devDependencies": {
"@babel/plugin-transform-react-jsx": "^7.28.0",
"@contember/bindx-react": "workspace:*",
"@types/babel__core": "^7.20.5",
"@types/babel__traverse": "^7.20.6"
},
"files": [
"dist",
"src"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/contember/bindx.git",
"directory": "packages/bindx-compiler"
}
}
Loading