refactor(bindx-react): stable EntityHandle identity (fine-grained reactivity) - #56
Merged
Conversation
matej21
force-pushed
the
refactor/stable-handle-identity
branch
from
August 19, 2026 09:47
c507b53 to
e1c725a
Compare
matej21
changed the base branch from
fix/drop-handle-dispose-lifecycle
to
main
August 19, 2026 09:47
…a changes EntityHandle is a stateless live view over the store, yet `useEntity` and `<Entity>` recreated it on every snapshot/version change (by feeding `snapshot`/`version` into the handle's useMemo deps) purely to hand memoized children a fresh reference. That defeated the store's fine-grained reactivity: any field change threw away the whole handle subtree and its caches, re-rendered the entire entity subtree, churned `useEffect([handle])` deps, and created the "superseded handle" concept the (now-removed) dispose lifecycle existed to clean up. The blessed leaf components (`<Field>`, `<HasOne>`, `<HasMany>`) already subscribe to the store themselves (useField/useAccessor -> useSyncExternalStore), so they re-render on data changes without needing a changing handle reference. Drop `snapshot`/`version` from the handle memo deps so the handle keeps a stable identity. The host still subscribes and re-renders (keeping inline `.value` reads in `children` fresh); memoized leaves now re-render only via their own subscription, so a field edit re-renders just the leaves that read it. Adds regression tests: stable accessor identity across a data-driven re-render (both `<Entity>` and `useEntity` paths), a memoized child holding the accessor no longer re-rendering on an unrelated data change, and `<Field>` staying live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAKx7JbV7Z7EVXofqRgwop
matej21
force-pushed
the
refactor/stable-handle-identity
branch
from
August 19, 2026 09:58
e1c725a to
9b75f34
Compare
matej21
added a commit
that referenced
this pull request
Aug 19, 2026
…#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
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
EntityHandleis a stateless live view over the store, butuseEntityand<Entity>recreated it on every snapshot/version change —snapshot/versionwere fed into the handle'suseMemodeps. The only reason was to handReact.memo-wrapped children a fresh reference so they'd re-render (see the oldEntityHandleRendererdoc comment: "children may be wrapped in React.memo and need a new handle reference to trigger re-renders").That reference-churn worked against the store's whole point — fine-grained reactivity:
fieldHandleCache/relationHandleCache, rebuilding them lazily. The "stable identity" promised inBaseHandle's docstring held only within one data version.<Field>/<HasOne>/<HasMany>, which already subscribe to the store themselves, were force-re-rendered redundantly.useEffect([handle])/useMemo([handle])deps in consumer code (the same identity-churn family as fix(bindx-react): stabilize useEntityList selection identity to prevent refetch loop #39's refetch loop).Change
The blessed leaf components already re-render via their own subscription (
useField/useAccessor→useSyncExternalStore), independent of the handle reference. So the recreation is redundant.snapshot/versionfrom the handleuseMemodeps inuseEntityandEntityHandleRenderer→ theEntityHandlekeeps a stable identity across data changes.accessor.valuereads inchildrenstay fresh (ergonomics preserved). Memoized leaves now re-render only through their own subscription — a field edit re-renders just the leaves that read it.The one behavioral change: a
React.memo-wrapped component that readsaccessor.valueinline without subscribing (i.e. without<Field>/useField) no longer auto-updates from a parent re-render — it must subscribe. That's the intended, teachable contract, and the primitives for it already exist and are the recommended path.Tests
tests/react/jsx/stableHandleIdentity.test.tsx:<Entity>and directuseEntitypaths;<Field>stays live (fine-grained reactivity);All three fail on the pre-change behavior (verified by temporarily restoring the deps) and pass after. Full suite: 1605 pass; the only failures are the playground browser tests (no live playground in this env — CI's
test-browserjob covers them) and one pre-existingbindx-formfailure unrelated to this change.🤖 Generated with Claude Code