Summary
useEntityList returns brand-new EntityAccessor proxies for every item on every store version bump — including items whose data did not change. Accessor identity is therefore unstable across renders, which defeats React.memo (and any identity-keyed caching) in list consumers: editing one item's field re-renders every sibling's subtree. In a page-builder canvas with ~100 blocks this makes every keystroke / delete reconcile the whole canvas.
Environment
Reproduction
const Row = React.memo(function Row({ item }) {
renderCounts[item.id] = (renderCounts[item.id] ?? 0) + 1
return <span>{item.name.value}</span>
})
function List() {
const authors = useEntityList(authorDef, {}, a => a.id().name())
if (authors.$status !== 'ready') return null
return (
<>
{authors.items.map(item => <Row key={item.id} item={item} />)}
<button onClick={() => authors.items[0]!.name.setValue('Renamed')}>rename</button>
</>
)
}
Click the button (changes only author-1): renderCounts['author-2'] increments too — the memo never bails because item is a new proxy object each render. The pushed test also asserts the root cause directly: authors.items[i] for an unchanged entity is not reference-equal across renders.
Expected behavior
Accessors for unchanged entities keep referential identity across renders — EntityHandle's own doc states "Stable identity (same instance across renders when id doesn't change)" (packages/bindx/src/handles/EntityHandle.ts). HasManyListHandle already implements this contract via its itemHandleCacheProxy; useEntityList should honor it too so React.memo list children can bail out.
Actual behavior
Both tests on the branch fail:
untouched[untouched.length - 1] is not reference-equal to untouched[0] (new proxy per render for an unchanged entity), and
- the memoized sibling row re-renders after an unrelated
setValue (Expected: 1, Received: 2).
No error is thrown — the effect is silent whole-list re-rendering.
Suspected root cause
packages/bindx-react/src/hooks/useEntityList.ts, getSnapshot (~lines 314–347): the list cache is keyed on store.getVersion() — the global store version — so any store change invalidates it, and the rebuild calls EntityHandle.create(...) per item, producing a fresh handle + proxy each time:
const storeVersion = store.getVersion()
if (cache && ... && cache.storeVersion === storeVersion ...) return cache.result
...
const items = state.items.map((item) => {
return EntityHandle.create<object>(item.id, entityType, store, dispatcher, ...)
})
A fresh root handle also cascades: its relationHandleCache starts empty, so every nested HasOne/HasMany handle — and their per-item proxy caches — are recreated as well. Downstream consumers (e.g. a BlockRepeater fed from entity.blocks.items) therefore see new item proxies for the entire list on every store change.
Suggested fix
Cache item handles per (entityType, entityId) in a Map held by the hook (or better, a store-level handle registry shared by all accessor producers), and reuse the cached proxy when rebuilding the snapshot — only create handles for ids not yet seen. Since EntityHandle is a stateless view over the store, reusing an instance is safe; the snapshot rebuild then preserves identity for unchanged ids while still reflecting order/membership changes through the array itself.
Workaround shipped downstream
We applied a temporary workaround in our project, marked
TODO [BindX] (<this-issue-url>): <description>. The workaround replaces React.memo's
identity comparison with an entity-id comparison for canvas block previews (an older
proxy reads identical live store data, so rendering through it is equivalent); we will
remove it once this issue is resolved.
Summary
useEntityListreturns brand-newEntityAccessorproxies for every item on every store version bump — including items whose data did not change. Accessor identity is therefore unstable across renders, which defeatsReact.memo(and any identity-keyed caching) in list consumers: editing one item's field re-renders every sibling's subtree. In a page-builder canvas with ~100 blocks this makes every keystroke / delete reconcile the whole canvas.Environment
@contember/bindx@0.1.46(version installed in the reporting project)contember/bindx@mainas of3c2fd0dtests/react/hooks/useEntityList/accessorIdentity.test.tsxbug/unstable-accessor-identity-defeats-memoReproduction
Click the button (changes only
author-1):renderCounts['author-2']increments too — the memo never bails becauseitemis a new proxy object each render. The pushed test also asserts the root cause directly:authors.items[i]for an unchanged entity is not reference-equal across renders.Expected behavior
Accessors for unchanged entities keep referential identity across renders —
EntityHandle's own doc states "Stable identity (same instance across renders when id doesn't change)" (packages/bindx/src/handles/EntityHandle.ts).HasManyListHandlealready implements this contract via itsitemHandleCacheProxy;useEntityListshould honor it too soReact.memolist children can bail out.Actual behavior
Both tests on the branch fail:
untouched[untouched.length - 1]is not reference-equal tountouched[0](new proxy per render for an unchanged entity), andsetValue(Expected: 1, Received: 2).No error is thrown — the effect is silent whole-list re-rendering.
Suspected root cause
packages/bindx-react/src/hooks/useEntityList.ts,getSnapshot(~lines 314–347): the list cache is keyed onstore.getVersion()— the global store version — so any store change invalidates it, and the rebuild callsEntityHandle.create(...)per item, producing a fresh handle + proxy each time:A fresh root handle also cascades: its
relationHandleCachestarts empty, so every nestedHasOne/HasManyhandle — and their per-item proxy caches — are recreated as well. Downstream consumers (e.g. aBlockRepeaterfed fromentity.blocks.items) therefore see new item proxies for the entire list on every store change.Suggested fix
Cache item handles per
(entityType, entityId)in aMapheld by the hook (or better, a store-level handle registry shared by all accessor producers), and reuse the cached proxy when rebuilding the snapshot — only create handles for ids not yet seen. SinceEntityHandleis a stateless view over the store, reusing an instance is safe; the snapshot rebuild then preserves identity for unchanged ids while still reflecting order/membership changes through the array itself.Workaround shipped downstream
We applied a temporary workaround in our project, marked
TODO [BindX] (<this-issue-url>): <description>. The workaround replacesReact.memo'sidentity comparison with an entity-id comparison for canvas block previews (an older
proxy reads identical live store data, so rendering through it is equivalent); we will
remove it once this issue is resolved.