Skip to content

refactor(bindx-react): stable EntityHandle identity (fine-grained reactivity) - #56

Merged
matej21 merged 2 commits into
mainfrom
refactor/stable-handle-identity
Aug 19, 2026
Merged

refactor(bindx-react): stable EntityHandle identity (fine-grained reactivity)#56
matej21 merged 2 commits into
mainfrom
refactor/stable-handle-identity

Conversation

@matej21

@matej21 matej21 commented Jun 25, 2026

Copy link
Copy Markdown
Member

Problem

EntityHandle is a stateless live view over the store, but useEntity and <Entity> recreated it on every snapshot/version change — snapshot/version were fed into the handle's useMemo deps. The only reason was to hand React.memo-wrapped children a fresh reference so they'd re-render (see the old EntityHandleRenderer doc 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:

  • Any field edit threw away the entire handle subtree and its fieldHandleCache/relationHandleCache, rebuilding them lazily. The "stable identity" promised in BaseHandle's docstring held only within one data version.
  • It re-rendered the whole entity subtree on any change, even leaves that don't read the changed field — so <Field>/<HasOne>/<HasMany>, which already subscribe to the store themselves, were force-re-rendered redundantly.
  • It churned 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).
  • It created the "superseded handle" concept that the dispose lifecycle (removed in the base PR) existed to clean up.

Change

The blessed leaf components already re-render via their own subscription (useField/useAccessoruseSyncExternalStore), independent of the handle reference. So the recreation is redundant.

  • Drop snapshot/version from the handle useMemo deps in useEntity and EntityHandleRendererthe EntityHandle keeps a stable identity across data changes.
  • The host still subscribes and re-renders, so inline accessor.value reads in children stay fresh (ergonomics preserved). Memoized leaves now re-render only through their own subscription — a field edit re-renders just the leaves that read it.
  • Field/relation handle caches now live for the entity's mount lifetime instead of being rebuilt every change.

The one behavioral change: a React.memo-wrapped component that reads accessor.value inline 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:

  • the entity/field accessor keeps a stable identity across a data-driven re-render — both the <Entity> and direct useEntity paths;
  • a memoized child holding the accessor does not re-render on an unrelated data change, while <Field> stays live (fine-grained reactivity);
  • the host still re-renders so inline reads stay fresh.

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-browser job covers them) and one pre-existing bindx-form failure unrelated to this change.

Stacked on #55 (drop dispose). This is the architectural follow-up: #55 removes the dead dispose machinery, this removes the handle recreation that made "superseded handles" a thing in the first place. Retarget to main once #55 merges.

🤖 Generated with Claude Code

@matej21
matej21 force-pushed the refactor/stable-handle-identity branch from c507b53 to e1c725a Compare August 19, 2026 09:47
@matej21
matej21 changed the base branch from fix/drop-handle-dispose-lifecycle to main August 19, 2026 09:47
matej21 and others added 2 commits August 19, 2026 11:56
…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
matej21 force-pushed the refactor/stable-handle-identity branch from e1c725a to 9b75f34 Compare August 19, 2026 09:58
@matej21
matej21 merged commit 5a86660 into main Aug 19, 2026
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
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