Skip to content

usePersist runs a full-store dirty scan per notification — sortable-list remove triggers O(N²) scans in one click #65

Description

@MalaRuze

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:

  1. a BlockRepeater (or any list) with sortableBy over ~100 items, and
  2. 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 getAllDirtyEntitiesisEntityDirtygetDirtyFields (deepEqual) dominating.

Mechanism, by code path:

  • bindx-repeater/src/utils/repairEntitiesOrder.tsfield.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.tsgetAllDirtyEntities() 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):

  1. Memoize getDirtyEntities() per store versionChangeRegistry can cache the result keyed on store.getVersion(); all subscribers within one notification burst then share one scan.
  2. Batch notifications — coalesce subscriber notification for mutations made within one synchronous transaction (microtask flush), so repairEntitiesOrder produces one notification instead of N.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions