Summary
usePersist subscribes globally with a getSnapshot that calls changeRegistry.getDirtyEntities() — a full-store scan (deepEqual of data vs serverData for every entity snapshot, plus computeReachableCreated()). React's useSyncExternalStore runs getSnapshot synchronously inside every store-change callback, and bindx-repeater's remove()/move() emit one notification per reindexed item (repairEntitiesOrder calls setValue on every trailing sibling). Deleting one block from a 100-item sortable list therefore triggers ~100 notifications × (number of mounted usePersist hooks) full-store scans — O(N²·M) work inside a single click. Profiled in a real page (102 blocks, 2 mounted usePersist hooks): the dirty-tracking frames (getAllDirtyEntities / isEntityDirty / getDirtyFields / getDirtyRelations) are the single largest cost of the delete click task (~24% of samples), ahead of React rendering.
Environment
@contember/bindx@0.1.46, @contember/bindx-repeater@0.1.46
- Behavior present on
contember/bindx@main as of 3c2fd0d (code inspection; profiling numbers from the downstream project)
Reproduction
Any page with:
- a
BlockRepeater (or any list) with sortableBy over ~100 items, and
- one or more mounted
usePersist / usePersistWithFeedback hooks (e.g. a save button),
then info.remove() on an item near the head of the list. Chrome CPU profile of the click task shows getAllDirtyEntities → isEntityDirty → getDirtyFields (deepEqual) dominating.
Mechanism, by code path:
bindx-repeater/src/utils/repairEntitiesOrder.ts — field.setValue(i) per trailing item ⇒ N notifications per remove/move.
bindx-react/src/hooks/usePersist.ts (~line 118) — useSyncExternalStore(store.subscribe, () => changeRegistry.getDirtyEntities()); React invokes the snapshot on every notification to decide whether to re-render.
bindx/src/store/DirtyTracker.ts — getAllDirtyEntities() iterates every entity snapshot and deep-compares scalar fields (rich-text JSON columns make each compare expensive) + reachability.computeReachableCreated() walks the relation graph.
Expected behavior
Store mutations occurring in one synchronous batch (a single event handler) should cost one dirty evaluation per subscriber, not one per touched field — and ideally the dirty evaluation should be incremental or memoized rather than a full-store deep scan.
Actual behavior
Each setValue in the repair loop synchronously runs the full-store scan once per mounted usePersist hook. With N sortable items and M persist hooks a single delete performs ~N×M full-store deep scans before React even renders.
Suggested fix
Any of these (they compose):
- Memoize
getDirtyEntities() per store version — ChangeRegistry can cache the result keyed on store.getVersion(); all subscribers within one notification burst then share one scan.
- Batch notifications — coalesce subscriber notification for mutations made within one synchronous transaction (microtask flush), so
repairEntitiesOrder produces one notification instead of N.
- Incremental dirty tracking — maintain a dirty-key set updated on write instead of scanning every snapshot on read.
Workaround shipped downstream
None practical — the scan lives behind useSyncExternalStore inside the hook. Downstream we mitigated the rendering half of the problem via #64's memo workaround; the notification-storm × full-scan cost remains and is now the dominant part of large-list mutations.
Summary
usePersistsubscribes globally with agetSnapshotthat callschangeRegistry.getDirtyEntities()— a full-store scan (deepEqualofdatavsserverDatafor every entity snapshot, pluscomputeReachableCreated()). React'suseSyncExternalStorerunsgetSnapshotsynchronously inside every store-change callback, and bindx-repeater'sremove()/move()emit one notification per reindexed item (repairEntitiesOrdercallssetValueon every trailing sibling). Deleting one block from a 100-item sortable list therefore triggers ~100 notifications × (number of mountedusePersisthooks) full-store scans — O(N²·M) work inside a single click. Profiled in a real page (102 blocks, 2 mountedusePersisthooks): the dirty-tracking frames (getAllDirtyEntities/isEntityDirty/getDirtyFields/getDirtyRelations) are the single largest cost of the delete click task (~24% of samples), ahead of React rendering.Environment
@contember/bindx@0.1.46,@contember/bindx-repeater@0.1.46contember/bindx@mainas of3c2fd0d(code inspection; profiling numbers from the downstream project)Reproduction
Any page with:
BlockRepeater(or any list) withsortableByover ~100 items, andusePersist/usePersistWithFeedbackhooks (e.g. a save button),then
info.remove()on an item near the head of the list. Chrome CPU profile of the click task showsgetAllDirtyEntities→isEntityDirty→getDirtyFields(deepEqual) dominating.Mechanism, by code path:
bindx-repeater/src/utils/repairEntitiesOrder.ts—field.setValue(i)per trailing item ⇒ N notifications per remove/move.bindx-react/src/hooks/usePersist.ts(~line 118) —useSyncExternalStore(store.subscribe, () => changeRegistry.getDirtyEntities()); React invokes the snapshot on every notification to decide whether to re-render.bindx/src/store/DirtyTracker.ts—getAllDirtyEntities()iterates every entity snapshot and deep-compares scalar fields (rich-text JSON columns make each compare expensive) +reachability.computeReachableCreated()walks the relation graph.Expected behavior
Store mutations occurring in one synchronous batch (a single event handler) should cost one dirty evaluation per subscriber, not one per touched field — and ideally the dirty evaluation should be incremental or memoized rather than a full-store deep scan.
Actual behavior
Each
setValuein the repair loop synchronously runs the full-store scan once per mountedusePersisthook. With N sortable items and M persist hooks a single delete performs ~N×M full-store deep scans before React even renders.Suggested fix
Any of these (they compose):
getDirtyEntities()per store version —ChangeRegistrycan cache the result keyed onstore.getVersion(); all subscribers within one notification burst then share one scan.repairEntitiesOrderproduces one notification instead of N.Workaround shipped downstream
None practical — the scan lives behind
useSyncExternalStoreinside the hook. Downstream we mitigated the rendering half of the problem via #64's memo workaround; the notification-storm × full-scan cost remains and is now the dominant part of large-list mutations.