Skip to content

fix(bindx-react): collect the selection again when Entity or EntityList children change - #156

Open
vparys wants to merge 7 commits into
mainfrom
fix/entity-collects-selection-per-children
Open

vparys wants to merge 7 commits into
mainfrom
fix/entity-collects-selection-per-children

Conversation

@vparys

@vparys vparys commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Fixes #154.

Problem

React reuses an <Entity> / <EntityList> across conditional branches that differ only in their children (same entity, same by / list options). useSelectionCollection memoized the collection on [entityType, depsKey, schemaRegistry] and read collect through a ref, so the new children rendered against the previous selection and threw UnfetchedFieldError. Seen in production on a tabbed detail page.

Change

  • useSelectionCollection takes a source (the user children function), passed by useRootSelection and EntityList; a new identity runs the collection again. Store-driven re-renders keep the same children, so they do not.
  • For one entity type, depsKey and schema registry the selection only grows (an EntityList reused for another entity with the same options starts afresh).
  • A has-many read under parameters the cache has not seen yet (<HasMany> aliases every filter / orderBy / limit / offset) replaces the cached aliases of that field the children no longer read, so a search or page held in parent state swaps its subquery instead of piling up. Without such a new alias nothing is dropped, so a hidden filtered list survives growth elsewhere; after a failed JSX analysis nothing is dropped either. Children covered by the cached selection get the same result object back (no refetch, no new handle), so a field toggled by parent state or a branch switched back and forth fetches each field once, and hiding a field never shrinks the query. New fields are merged into a deep copy, so neither the cached selection held by useEntity nor component selections are mutated.

Tests

tests/react/jsx/entityBranchSwitchSelection.test.tsx:

  • <Entity> and <EntityList> branch switch with the same by / options (both fail on main with the reported error);
  • toggling a field on and off does not refetch;
  • an <EntityList> reused for another entity does not carry the previous entity's fields into its query;
  • an <Entity> switched to another record starts its selection afresh;
  • a has-many filter changed three times keeps one subquery for it, the newest;
  • a hidden filtered has-many survives growth elsewhere without a refetch;
  • a nested <Entity> is not refetched when the outer entity re-renders on a store change;
  • parent re-renders with the same fields do not refetch.

With a compiled selection, the runtime walk (validate mode only) does not grow, so the under-fetch diff compares against what the current children read.

bun run test: 2532 pass. bun run typecheck clean.

Known gaps

  • No test yet for validate mode across a branch switch (the compiled path has its own tests; the runtime walk there no longer grows).

  • Until the refetch for newly needed fields lands, they read as empty instead of loading: Entity renders never-fetched fields as empty data while its selection grows #155. This already happened on the compiled path and on the first mount of a nested <Entity>; this PR makes it reachable from a runtime branch switch, where it used to throw.

  • Every parent re-render with an inline children now runs the collection (the result stays cached). The source doc recommends a stable children under parents that re-render often.

  • EntityCreateModeInner still runs useRootSelection and ignores the result; it now does so on every parent render. Left as is.

…st children change

React reuses an <Entity> or <EntityList> across conditional branches that differ only in their children. The collection was memoized on entity type and by/options alone, so the new children rendered against the previous selection and threw UnfetchedFieldError. The children function is now part of the memo key; a store-driven re-render keeps the same children and does not collect again, and an unchanged query keeps the same result object.

Fixes #154
Re-collecting on every new children function made a field toggled by parent state refetch on every toggle, and hiding a field shrank the query. The selection now only grows: children covered by the cached selection return it unchanged, and new fields are merged into a deep copy so neither the cached selection nor component selections are mutated.
…d registry

Neither Entity's by key nor EntityList's options key names the entity, so an EntityList reused for another entity with the same options merged the previous entity's fields into its query. The cache now records entity type, depsKey and schema registry, and grows only when all match. With a compiled selection the runtime walk no longer grows, so validate mode diffs against what the current children read.
…der new props

An equal query under another entity, depsKey or registry now returns a new cache object instead of rewriting the shared one. A skipped compiled walk passes no source, so a new children identity does not re-run it. Covers a by change with a test that the selection starts afresh.
@vparys
vparys requested a review from matej21 November 3, 2026 12:17

@matej21 matej21 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review round 1: review agent (Claude Opus 5.5), code reading only (tests not run), commit 63ee3b03. Verdict: changes.

The #154 fix is correct and the branch-switch tests cover it directly. One issue with the "selection only grows" rule needs a bound before merge.

Major: the grow rule never stops for has-many params. useSelectionCollection.ts:145-162 together with HasMany.tsx:95-103.

  • <HasMany> gives every distinct filter / orderBy / limit / offset its own alias (generateHasManyAlias). A new param set is therefore never "covered": it is always added, and old aliases are never dropped.
  • Failure scenario: <Entity by=…>{a => <HasMany field={a.comments} filter={{ text: { containsCI: search } }} limit={10} offset={page * 10}>…}</Entity>, with search or page held in parent state. Every keystroke or page change adds a new alias, and the refetch carries every earlier subquery as well (comments_x1, comments_x2, …). The query and the cached selection grow for as long as the component is mounted.
  • depsKey covers only by and the list options, never nested params.
  • Suggested fix: shrink when a has-many alias's params are replaced, or grow by field name and drop superseded aliases. Add a test.

Minor: "store-driven re-renders keep the same children" holds only for the entity's own subscription. useRootSelection.ts:83, EntityList.tsx:83.

  • A nested <Entity> / <EntityList> inside another entity's children gets a new inline children on every outer re-render, and the outer entity re-renders on every store change, including keystrokes. Each render then runs children(collector) and collectSelection again.
  • There is no refetch, because the covered check short-circuits. The cost is CPU per keystroke on large nested forms.
  • Worth a changelog note, or a cheaper check before the walk.

Minor: switching branches now fails silently instead of throwing (#155). Newly needed fields read as empty until the refetch lands, so in an edit form a user can type into an input that looks empty while the real value is still loading. Worth scheduling #155 together with this PR.

Nit: isSelectionCovered ignores the isRelation / isArray flags (useSelectionCollection.ts:75), while mergeSelections uses them. It is harmless today.

Nit: in validate mode with grow: false and an inline source, runtimeResult changes whenever a toggled field changes the query, so the diff effect can repeat the same warnings.

Compiler path: no conflict.

  • It passes source: undefined and the ::compiled key, so the walk never re-runs.
  • The babel plugin hoists the compiled selection per JSX site, so a branch switch gets a new compiledSelection and its memo recomputes.
  • The semantics differ by path: the compiled selection is replaced on a switch and can shrink, while the runtime selection only grows. Worth documenting.

Missing tests:

  • has-many params changing (the major above);
  • a nested <Entity> under an outer entity that re-renders from store changes;
  • validate mode across a branch switch (already noted in the PR).

… instead of growing

<HasMany> gives each filter/orderBy/limit/offset its own alias, so under the grow-only rule a search or page held in parent state added a subquery on every change. A cached has-many alias of a field the collected children read under other parameters is now dropped before the merge. The covered check also compares the relation flags, and the docs spell out the nested-Entity collection cost and that a compiled selection does not grow.
… bring a new one for its field

Any read of the same field, including the plain one the collector records, counted as a replacement, so unrelated growth dropped a filtered list that was only hidden. Aliases are now dropped only when the collected children read the field under parameters the cache has not seen, and not at all when the JSX analysis failed. The filter test also checks that the newest alias is the one fetched.
@vparys

vparys commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Howdy, partner 🤠 Thanks for ridin' through this one. Here's how the herd got sorted.

Major: has-many params stampede. You're dead right, that cattle would've grazed till the end of time. Fixed in 65f443a (dcab456 had the first, too-greedy pass):

  • When the children read a has-many under parameters the cache ain't seen yet, the cached aliases of that same field that the children no longer read get run off before the merge. A search or page in parent state now swaps its subquery instead of draggin' every old one along.
  • No new alias, no roundup. A hidden filtered list survives when the selection grows somewhere else, so comin' back to it costs no refetch.
  • If the JSX analysis throws, nothin' gets dropped. A busted analysis don't see the aliased <HasMany>s, so it ain't fit to judge what's been replaced.
  • Tests: the filter changed three times keeps exactly one subquery for it, and it's the newest (containsCI: 'React'). A hidden filtered has-many survives growth elsewhere with zero extra queries.

Minor: nested <Entity> walk on every outer re-render. True as a rattlesnake's rattle. The source and hook docs now say it plainly: a nested <Entity> re-collects whenever the outer one re-renders, and the result stays cached, so there's no refetch, only the walk. There's also a test that a nested <Entity> doesn't refetch when the outer one re-renders on a store change. I'd rather not saddle this PR with a cheaper pre-check. If profiles show it hurtin', that's a ride of its own.

Minor: silent empty instead of a throw (#155). Agreed, that's the next trail I'm takin'. I kept it out of this PR on purpose: loading-until-covered versus keep-the-old-children is a design call, and I'd like your word on it in #155 before I build it.

Nits:

  • isSelectionCovered now checks isRelation / isArray too.
  • The compiled path is documented: it doesn't grow. It gets replaced on a branch switch and can shrink.
  • Validate mode repeatin' warnings: with grow: false the runtime result tracks the current children, so a toggle that changes the query re-runs the diff. That's the honest diff for what's on screen, and I left it be.

One more thing I spotted out on the range, outside this PR: HasMany.getSelection calls props.field.map(...) on the outer collector ref, so every filtered <HasMany> also fetches the plain, unfiltered relation (tags { id name } next to tags_<hash>). It's older than this branch. Want me to open an issue for it?

Still to rope: no validate-mode test across a branch switch yet (noted in the PR body). bun run test: 2532 pass, typecheck clean.

Happy trails 🐎

@vparys

vparys commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Opened that one as #158, with a failing repro on bug/hasmany-filter-also-fetches-unfiltered-relation 🤠

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.

Entity keeps a stale selection when React reuses it with different children

2 participants