Skip to content

fix: consolidate correctness and reactivity follow-ups - #85

Open
matej21 wants to merge 32 commits into
mainfrom
fix/consolidated-follow-ups
Open

fix: consolidate correctness and reactivity follow-ups#85
matej21 wants to merge 32 commits into
mainfrom
fix/consolidated-follow-ups

Conversation

@matej21

@matej21 matej21 commented Aug 20, 2026

Copy link
Copy Markdown
Member

Summary

  • correct nested-create selection and pairing, dirty scans, and persist lifecycle events
  • honor nested entity persistence vetoes and emit lifecycle outcomes for inline creates
  • stabilize list accessor identity, condition subscriptions, relation caches, and store notifications
  • subscribe implicit entity and interface props without changing the component hook count
  • propagate nested relation changes through live parent edges so $connect(id) re-points sibling subscriptions
  • keep optional filter-name hooks stable when components switch between context and explicit names
  • analyze JSX returned by relation columns and add selection-erased entity view types
  • harden the non-browser CI gate and flaky popover selection in browser tests
  • enforce React hooks linting, remove stale directives, and fix conditional hook calls

Integration notes

This consolidates and supersedes #29, #71, #72, #73, #74, #77, #78, #79, #80, #81, #82, #83, and #84 so the fixes can be reviewed and merged as one unit.

Nested nullable <HasOne> and $connect(id) sibling-subscription regressions are normal passing tests. Relation notifications now update the owning entity and propagate through its live ancestors while notifying global subscribers once.

Review follow-ups prevent a vetoed nested create from being included in its parent's mutation, emit entity:persisted or entity:persistFailed for inline creates, and subscribe lazily collected interface props before the component's first runtime render.

The lint gate reports 18 exhaustive-deps warnings and zero errors. Exhaustive dependency findings remain warnings by design.

Verification

  • bun run typecheck
  • bun run lint — 0 errors, 18 warnings
  • bun run test — 1,865 passed across 173 files
  • GitHub Actions Browser Tests — passed
  • git diff --check origin/main...HEAD

matej21 and others added 30 commits August 20, 2026 14:35
…eates (#70)

buildSelectionFromOps unioned scalar fields across sibling create/update ops
but kept nested relations in a Map keyed by field name, so the last sibling's
shape won. Two blocks whose nested `button` creates carried different fields
emitted a node selection covering only one of them, so the response could not
be content-matched back to the other sibling: its nested creates kept their
temp IDs while still being committed as existing on the server, and the next
edit went out as an update keyed by `__temp_...`, which the API rejects.

Nested payloads are now accumulated per field name across every sibling and
fed back through buildSelectionFromOps, so the union is recursive by
construction for both has-one and has-many, nested-in-nested included. The
walker is rebuilt around one primitive (buildSelectionFromDataObjects) and is
now cast-free; a latent Object.entries(null) crash on a present-but-null
`data` is guarded by the new isRecord type guard.

The selection tests live under tests/unit/ rather than tests/bindx-client/
because only tests/unit, tests/react and tests/cases are in the CI script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
…70)

Widening the mutation node selection also widened the content matcher: the
selection is its input, so selecting a nested relation that last-wins
previously dropped makes isCreateDataMatchingNode recurse into a subtree it
used to skip. Any scalar the server does not echo back byte-identically —
an ordinary datetime normalisation is enough — then failed the match for the
entire parent create op, and the greedy loop discarded that sibling and every
entity nested under it. A one-entity temp-ID leak became a three-entity one,
silently, with success: true.

extractNestedResultsFromNode's greedy first-fit loop is replaced by a pairing
pass: first pair every op that has exactly one candidate row, removing that
row and looping, since consuming a row often makes another op unique; then
fall back to first-fit for ops that remain ambiguous; then, if exactly one op
and one row are left unpaired, pair them.

The uniqueness pass is what makes elimination sound. Bare elimination on top
of the greedy loop would have widened a pre-existing bug: a subset payload
steals its sibling's row today and the sibling ends up unmapped, but with
plain elimination the sibling would instead be mis-mapped onto the subset's
row — silent cross-wiring rather than a leak. Pairing the precise payload
first removes that precondition, which also repairs the existing bug.

isCreateDataMatchingNode is untouched: strict comparison is still the
evidence, it is just no longer the sole arbiter. The matcher must not treat
"cannot identify" as "discard".

Elimination is deliberately capped at one op and one row; with two
simultaneously unmatchable siblings the entities keep their temp IDs rather
than being guessed at, which a test pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
ChangeRegistry.getDirtyEntities() ran a full-store scan — deepEqual of data
vs serverData for every snapshot, plus a reachability walk and a per-entity
dirtyFields/dirtyRelations pass. usePersist feeds it to useSyncExternalStore,
which React runs synchronously inside every store notification, and list
reorder helpers emit one notification per reindexed item. A single delete in
a large sortable list therefore cost O(N*M) full scans before React rendered.

The result is now memoized per store write version. The key is deliberately
NOT getVersion(): that is the subscription manager's globalVersion, bumped
only inside notifying paths, and several dirtiness-changing writes do not
notify — createEntity registers its root AFTER its last notification,
registerParentChild un-registers a root silently, commitAllRelations and
resetAllRelations never notify, and refreshServerData can skip notifying.
Keying on it would serve a stale empty dirty set to the very first read, which
happens synchronously inside createEntity's own notification.

Instead getDirtyVersion() sums monotonic sub-store mutation counters, the
pattern ReachabilityAnalyzer already uses: a new dataWriteVersion on
EntitySnapshotStore plus the meta mutation/editable counters, the relation
counter and the root registry counter. A sum of monotonic counters is strictly
increasing on any bump, so an unchanged sum proves no dirtiness-relevant write
happened, independent of whether anything was notified.

getDirtyEntitiesNotInFlight() keeps filtering on every call, since in-flight
state changes without any store write.

This is the memoization fix only. Coalescing the notification storm and
incremental dirty tracking are separate; a delete in an N-item list still
fires ~N notifications, each now O(1) instead of a full scan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
#65)

Review follow-up to the dirty-scan memo. The memo is only as correct as its
key, and `dataWriteVersion++` was hand-maintained across 11 call sites in
EntitySnapshotStore. A contributor adding a mutating method — or an
early-return branch to an existing one — and forgetting the bump would break
nothing loudly: the store would serve a stale dirty set for the rest of the
session, and the user would see a dead Save button.

Writes now go through writeSnapshot/deleteSnapshot, which own the bump, the
same shape HasOneStore uses for writeRelation/deleteRelation. bumpVersion is
the one documented bypass: it reuses the same data/serverData refs so it
cannot change dirtiness, and it runs once per ancestor on every notification,
so bumping there would keep the cache permanently cold.

The chokepoint makes it hard to get wrong; the new guard test is what
enforces it. It classifies every name on the prototype into mutating /
non-mutating / internal and fails on anything unclassified, then asserts each
mutating method moves the key and each non-mutating one does not — so a new
method cannot be added without a deliberate decision about its bump.

Also: hasDirtyEntities() shares the memo instead of running its own full scan;
getDirtyVersion() is marked @internal and documents the constraint the memo
now depends on — snapshot values must be replaced, never mutated in place,
since createEntitySnapshot freezes only the top level.

The memoization test no longer asserts that a mid-write read sees an empty
dirty set. That pinned one of the known missing-notification bugs as expected
behaviour and would have handed a red test to whoever fixes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
…useEntityList

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#64)

useEntityList rebuilt every item's EntityHandle on every store version bump,
including items whose data did not change, so item accessor identity was
unstable across renders. That defeats React.memo in list consumers — editing
one item re-rendered every sibling's subtree — and it cascaded: a fresh root
handle starts with an empty relationHandleCache, so every nested HasOne and
HasMany handle and their per-item proxy caches were rebuilt too.

Items are now cached per (entityType, entityId) for the hook's lifetime: one
handle and one proxy per id, reused for the id's whole life, with ids no
longer in the list evicted on rebuild. The cache is dropped whenever a handle
construction input changes, notably selectionMeta.

Identity is deliberately NOT a change signal. Making it one would require a
total per-entity change signal, and EntitySnapshot.version is not one —
notifyEntitySubscribers bumps the parents' versions but never the notified
key's own, so errors, touched, scheduled-deletion and optimistic persisting
flags never move it. Keying re-wraps on it looked right and silently broke
memoized rows for all of those. Instead identity means identity, and change
delivery is the subscription's job — the contract PR #56 established for
accessors generally.

The reproducer's memoized Row is amended to subscribe via useField, which is
the contract it now tests. All seven assertions are byte-identical to the
original; only the component and its props type changed.

Known limit, documented on the cache: a membership change on a DESCENDANT
relation does not reach a subscriber on the root item, because
notifyRelationSubscribers does not walk up the parent chain. Such a row must
subscribe to the owner of the relation it renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
…ear (#64)

Review follow-up to the stable accessor identity. Once identity stops churning,
anything memoized that does not subscribe goes silently stale. Two such places
were reproduced, both of which worked before only because identity churn
happened to re-render them.

createComponent() with an implicit entity prop never subscribed. useRenderProps
called useAccessor only for entity props carrying a selector, so
.entity('author', schema.Author) — the mode whose whole point is implicit
selection collection — got no subscription while ComponentImpl is memo-wrapped.
Editing the entity left the component rendering the old value indefinitely.
All declared entity props are now subscribed. The hook count stays constant:
entityConfigs is fixed when buildComponent runs and never mutated, and a
declared-but-unpassed prop still consumes exactly one slot through
useAccessor's noop path.

SnapshotStore.clear() notified global subscribers only, so a row subscribed
exactly as the accessor contract prescribes kept rendering wiped data — and
unlike the descendant-relation limitation, no subscription a consumer could
write fixed it. clear() is the documented logout / teardown / schema-switch
path. SubscriptionManager gains notifyAll(), which bumps the global version
once and then dispatches to every entity, relation and global subscriber; it
snapshots the registries first, since a subscriber may unsubscribe itself or a
sibling while being notified. Registrations are deliberately not dropped —
those components are still mounted and still own their unsubscribe closures.
notify() semantics are untouched.

Eviction is now tested. The earlier claim that a black-box test is vacuous was
wrong: an id that leaves and re-enters the list must get a NEW accessor, which
passes with the eviction loop and fails without it, entirely through the public
hook.

ItemAccessorCache moves to its own module — useEntityList.ts had grown past the
file-size guideline — and a comment pins the invariant that the items array
identity is deliberately unstable, since nothing in the DataGrid render chain
subscribes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
Both the EntityHandle.intercept('entity:persisting', ...) interceptor
and the useOnEntityEvent('entity:persisted', ...) hook are publicly
advertised (the hook's own JSDoc literally documents the persisted
example), but never fire at runtime. The events/eventFactory.ts
createBeforeEvent / createAfterEvent switch statements have no case
for SET_PERSISTING (the action BatchPersister dispatches around the
mutation), so the EventEmitter never receives a before/after event
to fan out.

The two new tests assert the canonical advertised usage and currently
fail with `Received length: 0`. The mutation itself succeeds — the
mock store reflects the new value — proving the gap is purely in the
event factory, not in the persist pipeline.

Same root cause exists for entity:persistFailed (failure path) and
entity:deleting / entity:deleted (DELETE_ENTITY action). Adding
failure injection to MockAdapter is out of scope here; mirroring the
fix once the persisting/persisted path lands is a one-line addition
in the BatchPersister catch branch and the eventFactory switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
entity:persisting, entity:persisted and entity:persistFailed were declared,
registrable through EntityHandle.intercept / useOnEntityEvent, documented in
the hook's own JSDoc — and never fired. BatchPersister dispatches
SET_PERSISTING around each persist; that flows through ActionDispatcher into
events/eventFactory.ts, whose switches have no case for it, so nothing was
ever emitted. Consumers registering before-save normalisation or after-save
invalidation silently did nothing. BlockEditor's orphaned-reference cleanup,
registered via useEntityBeforePersist, is a victim inside this repo.

The events are now emitted from BatchPersister rather than the action factory.
persistedId is only known after the server response, and the persist lifecycle
is multi-step — the factory mapping suits simple state changes, not this.
eventFactory.ts is deliberately untouched, so there is no double emission.

entity:persisting runs through the interceptor pipeline before mutations are
built, so a hook's store writes land in the same save. Cancellation follows
the ActionDispatcher precedent — a null from an interceptor vetoes that one
entity, the rest of the batch proceeds, and the vetoed entity stays dirty and
retryable, counted as skipped rather than failed.

markInFlight now claims the batch before the interceptor await, so the
re-entrancy guard still holds across the new suspension point. EventEmitter
gains hasInterceptors() so a persist with no hooks registered skips the async
pipeline entirely and keeps its original synchronous timing.

Delete entries in a persist batch also get persisting/persisted. That is the
persist lifecycle; entity:deleting / entity:deleted remain unemitted and are
a separate unit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
`bun run test` enumerated the directories it covered, so 20 test files
never ran in CI: the bindx-form, bindx-uploader and bindx-generator
package suites plus tests/bindx-client and tests/repeater. The has-many
materialisation bug fixed in #76 sat failing in
packages/bindx-form/tests the whole time, behind a green board.

Use an ignore pattern instead of an allow-list, so a new test directory
is covered by default and cannot silently drop out again. Drop test:all,
which now differs only by also running tests/browser and fails without a
live playground.

Gate: 1531 tests across 129 files -> 1737 across 149, 0 fail.
<Switch> called useField inside a loop over its <Case> children, behind a
rules-of-hooks eslint-disable and a comment asserting a stable count that
nothing enforced. SwitchProps.children is ReactNode, so a conditionally
rendered <Case> type-checks and then crashes React with "Rendered more
hooks than during the previous render".

Separately, a cond.* DSL condition subscribed to nothing: If and Case
passed null to useField whenever the condition was a Condition object
rather than a bare FieldRef, while evaluateCondition kept reading the
values live. Inside a memoized subtree, where the parent's re-render does
not reach the child, the branch never re-evaluated.

Both are the same missing capability — subscribing to a number of refs
that is not known at build time. useFields(refs) takes a single
useSyncExternalStore subscription over N refs, so the hook count stays
constant, and <Switch> and <If> now feed it their condition fields. It
widens ref to accessor through overloads exactly as useAccessor already
does, so no cast is involved.

The repo has no eslint config, so the rules-of-hooks disable removed here
was never enforced by anything — which is how the crash survived.
HasManyListHandle cached an EntityHandle and a proxy per item id and
released neither: the whole file had no delete, no clear and no dispose.
A paginated, filtered or repeatedly refetched has-many therefore grew for
as long as the parent handle lived.

The items getter now prunes keys missing from the presented id list,
before the handles are resolved, so a still-listed item is never touched
and keeps its identity by construction. Two guards go with it. Pruning is
skipped while the parent persists, because the presented list hides
planned additions and pruning against it would evict a just-added item
and re-mint it seconds later. And lookups canonicalise a temp id through
its persisted id, so a rekeyed item reuses one handle instead of minting
a duplicate for the same entity.

A rekeyed entry is evicted rather than migrated. EntityHandle.id returns
the id the handle was constructed with, so a carried-over handle would
keep reporting a dead temp id while its own $fields.id.value reported the
real one.

itemHandleCacheRaw goes in the same pass: it was written and never read
anywhere in the repo. The proxy is the reference that keeps the handle
alive, so caching the raw handle separately bought nothing.
Four SnapshotStore write paths were suspected of mutating state without
notifying subscribers. Investigating each settles them:

- createEntity - CONFIRMED, user-visible. roots.register() runs after
  setEntityData and setExistsOnServer have already notified, and it is
  the write that makes the entity count as a create. A save indicator
  built on store.subscribe + getAllDirtyEntities().length renders 0
  while the store holds a dirty create.
- clearAllServerErrors - CONFIRMED. Its sibling clearAllErrors notifies
  for the same write. Both in-tree callers happen to self-heal through
  ordering, so this reaches users through the exported action only.
- sweepUnreachableCreated - NOT a bug. Its only mutation is removeEntity,
  which notifies.
- unregisterRootEntity - NOT a bug. Both callers sweep on the next line,
  and the sweep notifies.

A fourth suspect, unregisterParentChild, no longer exists: ae80e75
removed it. docs/issues/032-memory-leaks.md still prescribes wiring it.

The two confirmed gaps are pinned with test.failing rather than left as
red reproducers, so they can live on main. Bun reports such a test as
passing while the bug is present, and fails the run the moment the
behaviour is fixed, forcing the marker off. The characterization tests
around them record why the other two suspects are non-issues.

No fix is included - this commit establishes the evidence.
The repo had no eslint config at all, while the source carried
`// eslint-disable-next-line react-hooks/rules-of-hooks` comments that
nothing has ever enforced. One of them sat over a `useField` call inside
a loop in <Switch>, under a comment asserting a "stable count" that was
false; it crashes React as soon as a <Case> is conditionally rendered.

Enable exactly two rules over packages/*/src and tests: rules-of-hooks as
an error, exhaustive-deps as a warning. eslint-plugin-react-hooks v7
ships 28 rules (the React Compiler set); none of the others are turned on
and no style preset is added, so the signal stays readable in a repo with
no lint history.

No CI job is added. `bun run lint` exits 1 today: 9 rules-of-hooks sites
(7 genuine hazards, 2 provably-stable false positives) and 25 phantom
errors from dead `@typescript-eslint/*` disable directives naming rules
no config defines. Turning the gate on has to wait for those.

Flat config is named .mjs because the root package.json has no
"type": "module".
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.
Consumers hand a selection-branded accessor to a helper typed for a
different selection and bridge the gap with `as unknown as`. The
diagnosis this was meant to fix - "the __selected brand is invariant" -
is wrong. `readonly __selected?: TSelected` is a readonly optional
property, i.e. an output position, and is already covariant: widening a
full accessor to a narrower selection compiles today.

What actually fails is the opposite direction, for three reasons, and
only one of them is the brand:

1. narrow -> full is rejected by the brand AND, independently, by
   EntityFieldsRef/EntityFieldsAccessor, which are keyed on
   `keyof TSelected` and so are missing the properties outright. It
   SHOULD be rejected: EntityHandle.fields throws UnfetchedFieldError
   for any field outside the selection, so widening in place is a real
   runtime bug. Loosening the brand would legalise it.
2. A free `TSelected` in a generic helper cannot be resolved at all - no
   variance annotation can fix an unknowable mapped-type key set.
3. TEntityName is invariant through FieldRefMeta.entityType, so a
   `string`-named accessor does not flow into a literal-named parameter.
   A second, independent cast generator.

So instead of changing an existing type, add two erased views:

    EntityRefLike<TEntity>      = EntityRefInterface<TEntity, unknown>
    EntityAccessorLike<TEntity> = EntityRefLike<TEntity> & { $data }

They erase TSelected and TEntityName, keep __entityType as the
discriminator, and deliberately omit the field proxy. Use them in
parameter positions that need entity identity and the
selection-independent API. A receiver that reads fields must still
declare the selection it needs - that cast was hiding a bug.

Nothing existing is modified. `__selected` also turned out to be
load-bearing for inference, not just checking: replacing it with
`unknown` breaks selection inference across ~30 sites.
Two SnapshotStore paths mutated observable state without telling anyone.
The pins added in the parent commit reproduced both; this removes them
and turns the pins into ordinary regression tests.

createEntity registered the create-root AFTER setEntityData and
setExistsOnServer had both already notified - and the root registration
is precisely the write that makes the entity count as a create. So both
notifications carried the pre-registration value and a save indicator
built on store.subscribe + getAllDirtyEntities().length rendered 0 while
the store held a dirty create.

Fixed by reordering rather than adding a notification, so the cost stays
at two notifications per create instead of three:

  setEntityData -> roots.register -> setExistsOnServer

The order is forced from both sides. The root must come after
setEntityData, because ReachabilityAnalyzer.walk() seeds a root only if
the snapshot exists, so registering first would be inert and would leave
a dangling root if the snapshot write threw. And it must come before
setExistsOnServer, which then carries the final notification. Moving
setExistsOnServer(false) last is observationally free: EntityMetaStore
defaults an unknown key to false. roots.register bumps mutationVersion,
which invalidates the reachability memo, so the subscriber woken by that
last notification recomputes and sees the create.

The undo journal is unaffected: the pre-image is captured inside
setEntityData before any root write in either order, and the per-kind
write guard already fuses snapshot, meta and roots into one `entity`
kind recorded by that same call.

clearAllServerErrors was simply silent where its sibling clearAllErrors
notifies for the same kind of write. Both in-tree callers self-heal by
ordering, so this only ever reached users through the exported action.

Known and unchanged: both siblings clear relation errors under the key
prefix but notify only the entity, so a subscribeToRelation consumer
still sees a stale value after either call.
@matej21
matej21 marked this pull request as ready for review August 20, 2026 14:45
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.

3 participants