Skip to content

fix(bindx-dataview): analyze the JSX a relation column renderer returns - #82

Closed
matej21 wants to merge 1 commit into
mainfrom
fix/relation-column-selection
Closed

fix(bindx-dataview): analyze the JSX a relation column renderer returns#82
matej21 wants to merge 1 commit into
mainfrom
fix/relation-column-selection

Conversation

@matej21

@matej21 matej21 commented Aug 20, 2026

Copy link
Copy Markdown
Member

The bug

All three selection-collection sites in createRelationColumn.tsx invoked the cell renderer against a collector proxy and discarded the JSX it returned:

collectSelection: (renderer, fieldRef) => {
	renderer(fieldRef)      // return value dropped
},

Selections were captured only as a side effect of property accesses on the proxy. So a declarative cell:

<DataGridHasOneColumn field={it.post}>
	{p => <HasMany field={p.tags}>{tag => <Field field={tag.name}/>}</HasMany>}
</DataGridHasOneColumn>

registered tags but never tag.name. The inner callback belongs to a nested component and is never invoked during collection, so the query came back with nested fields missing and cells rendered empty.

The workaround consumers landed on is to write the cell imperatively — p.tags.map(t => t.name.value) — purely so the proxy sees the accesses.

The fix

Feed the renderer's return value through collectSelection() (already exported from @contember/bindx-react; nothing new had to be exposed) and merge the result into the relation's own child SelectionScope.

The scope is the load-bearing part: the collected entries are relative to the related entity (path: ['members'], not ['organization','members']), so merging them into the row-level selection would attach them to the wrong entity. The child scope is reachable off the collector field ref via its SCOPE_REF getter.

The merge is additive and safe against double-counting — HasMany/HasOne getSelection already register into the child scope as a side effect of map() / $entity, and Field.getSelection returns null during collection, so scalars come purely from the proxy touch. Outside the collection phase the scope lookup returns null and the merge is a no-op.

The existing .map()-on-proxy pattern keeps working — this adds a path, it does not replace one, and two regression tests guard it.

Three sites, not two

buildLeaf's relatedSelection had the identical defect. Its consumer is extractScalarFieldNames → the relation filter's fulltext handler, so a declaratively-rendered <HasMany> stayed in the list as a bare scalar and was handed to createFullTextFilterHandler as a searchable path — a fulltext contains against a has-many relation. It now carries its nested selection and is correctly excluded.

(An earlier draft of this description claimed the bug caused blank rows in the filter popover. That was wrong: SelectDataView runs its own registry-aware collectSelection, so rows were never blank. The search box is the real consumer.)

Known gap, deliberately left

buildLeaf builds its proxy without a schemaRegistry, and one is not reachable there — buildLeaf runs from staticRender(props), outside React rendering, and props carries no registry. Threading one in means changing the staticRender contract.

Measured consequence, at nesting depth ≥ 2 only:

case row selection (has registry) relatedSelection (none)
org => <><Field field={org.country.code}/><Field field={org.country.value}/></> country{code,id,value} country{code,id}value dropped

Without a registry the nested ref takes the non-has-one proxy branch where prop in target wins, so a related field named like a collector built-in (value, items, length, map, errors, isDirty, …) resolves to the stub. Top level is immune — the root proxy uses an explicit allowlist. Neither this nor the related "relation touched but never entered" case affects the declarative path this PR fixes.

Testing

10 new tests in tests/react/dataview/relationColumnSelection.test.tsx, asserting on the collected SelectionMeta rather than rendered output.

Negative controls, run separately per fix:

  • reverting the two collectSelection fixes → 3 fail (nested <HasMany> and nested <HasOne> children fields missing, in both column variants)
  • reverting only the relatedSelection fix → 2 fail, with Expected relation "members" with a nested selection, got: members — which is itself the evidence for the fulltext bug above

Three tests pass in both directions and are kept as behaviour locks, not dressed up as regressions: a top-level <Field field={org.name}/> cannot fail without the fix, because org.name is evaluated while the element is being constructed, so the proxy is touched before the JSX is ever discarded. Only nested-component renderers were broken.

Gates

bun test tests/react/dataview/ 112 pass / 0 fail · bun run typecheck clean · bun test --path-ignore-patterns='**/tests/browser/**' 1747 pass / 0 fail / 150 files (baseline 1737 / 149; delta is entirely this PR's new tests).

All three selection-collection sites in createRelationColumn invoked the
cell renderer against a collector proxy and threw the returned JSX away.
Selections were captured only as a side effect of property accesses on
the proxy, so a declarative renderer such as

    {p => <HasMany field={p.tags}>{tag => <Field field={tag.name}/>}</HasMany>}

registered `tags` but never `tag.name` — the inner callback is owned by a
nested component and is never invoked during collection. Rows came back
with their nested fields missing, which forced consumers to write cells
imperatively as `p.tags.map(t => t.name.value)` purely so the proxy would
see the accesses.

Feed the renderer's return value through collectSelection() and merge the
result into the relation's own child SelectionScope. The scope matters:
the collected fields are relative to the related entity, so merging them
into the row-level selection would attach them to the wrong entity.

The merge is additive — <HasMany>/<HasOne> getSelection already register
into the child scope as a side effect of map()/$entity, and Field returns
null during collection, so nothing is double-counted. The existing
.map()-on-proxy pattern keeps working; it is guarded by a test.

buildLeaf's relatedSelection had the same defect. It feeds
extractScalarFieldNames, so a declaratively-rendered has-many stayed in
the list as a bare scalar and was handed to the fulltext filter handler
as a searchable path — a `contains` against a relation. It now carries
its nested selection and is correctly excluded.

Known gap, left alone: buildLeaf builds its proxy without a
schemaRegistry, which is not reachable from staticRender(props). At
nesting depth >= 2 a related field named like a collector built-in
(`value`, `items`, `length`, ...) resolves to the stub and is dropped.
Top level is immune - the root proxy uses an allowlist.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant