Skip to content

fix(bindx): announce the writes that made the store go stale - #84

Closed
matej21 wants to merge 1 commit into
test/store-notification-pinsfrom
fix/store-silent-writes
Closed

fix(bindx): announce the writes that made the store go stale#84
matej21 wants to merge 1 commit into
test/store-notification-pinsfrom
fix/store-silent-writes

Conversation

@matej21

@matej21 matej21 commented Aug 20, 2026

Copy link
Copy Markdown
Member

Stacked on #80. Its base is test/store-notification-pins, not main#80 adds the reproductions, this removes them. Merge #80 first, or say the word and I will squash the pair into a single PR against main.

Two SnapshotStore write paths mutated observable state without notifying. #80 reproduced both with test.failing pins; this fixes them, which turns those pins red (this test is marked as failing but it passed) and so also un-marks them into ordinary regression tests.

createEntity — the user-visible one

this.setEntityData(entityType, id, data, false)   // notifies
this.setExistsOnServer(entityType, id, false)     // notifies
this.roots.register(this.getEntityKey(entityType, id))   // silent

The root registration is the write that makes the entity count as a create — the comment directly above it says so. Both notifications therefore carried the pre-registration value.

Observable effect: mount a plain <Entity create> and a save indicator built on store.subscribe + getAllDirtyEntities().length — the shape usePersist uses — renders 0, while getAllDirtyEntities() returns one create.

Reordered, not re-notified

setEntityData  ->  roots.register  ->  setExistsOnServer

Adding a notification after roots.register would have worked too, and was rejected: it makes useEntityList adding N drafts cost 3N notifications instead of 2N. The reorder costs nothing.

The position is forced from both sides:

  • After setEntityDataReachabilityAnalyzer.walk() seeds a root only if (entitySnapshots.has(key)), so registering first would be inert, and would leave a dangling root if the snapshot write threw.
  • Before setExistsOnServer, which then carries the final notification. Moving it last is observationally free: EntityMetaStore.existsOnServer defaults an unknown key to false, so for a fresh entity that call is bookkeeping.

roots.register bumps mutationVersion, invalidating the reachability memo, so the subscriber woken by that last notification recomputes and sees the create.

Undo journal is unaffected: the pre-image is captured inside setEntityData (journal?.recordEntity(key)) before any root write in either order, so exportEntityCell's isRoot / present:false capture is identical. The per-kind write guard fuses snapshot + meta + roots into one entity kind, already recorded by that same call.

clearAllServerErrors

Silent, where its sibling clearAllErrors notifies for the same kind of write, and getVersion() did not move either — so no consumer, entity or global, could observe the clear.

Both in-tree callers self-heal by accident of ordering (BatchPersister dispatches a notifying setPersisting(true) right before; mapServerErrors follows every clear with a notifying addFieldError), so this only ever reached users through the exported action. Their re-render count goes from 1 to 2 per persist attempt — not a per-keystroke path.

Not fixed, recorded

Both siblings clear relation errors under the key prefix but notify only the entity, so a subscribeToRelation consumer still sees a stale value after either call. Fixing that asymmetry would change the notification counts these tests now pin, so it is a separate decision.

The two other suspects from #80 stay untouched and their characterization tests are unchanged: sweepUnreachableCreated delegates to a notifying removeEntity, and unregisterRootEntity's callers sweep on the next line. Neither is a bug.

Gates

  • bun test tests/unit/store/ tests/react/storeNotifications/ — 194 pass / 0 fail
  • bun run typecheck — exit 0
  • bun test --path-ignore-patterns='**/tests/browser/**' — 1751 pass / 0 fail / 154 files, identical totals before and after (the delta is only the 5 pins flipping red→green)

Negative control: with the store fix reverse-applied and the markers still off, exactly the 5 un-marked tests fail. The two control and two characterization tests pass in both directions.

Comment headers in the four files were rewritten from "known-broken pin" to regression notes — they described test.failing semantics that no longer apply. No assertion, test name or expect was touched; verified by diffing non-comment lines and by the unchanged expect() count (1959).

Two SnapshotStore paths mutated observable state without telling anyone.
The pins added in the parent commit reproduced both; this removes them
and turns the pins into ordinary regression tests.

createEntity registered the create-root AFTER setEntityData and
setExistsOnServer had both already notified - and the root registration
is precisely the write that makes the entity count as a create. So both
notifications carried the pre-registration value and a save indicator
built on store.subscribe + getAllDirtyEntities().length rendered 0 while
the store held a dirty create.

Fixed by reordering rather than adding a notification, so the cost stays
at two notifications per create instead of three:

  setEntityData -> roots.register -> setExistsOnServer

The order is forced from both sides. The root must come after
setEntityData, because ReachabilityAnalyzer.walk() seeds a root only if
the snapshot exists, so registering first would be inert and would leave
a dangling root if the snapshot write threw. And it must come before
setExistsOnServer, which then carries the final notification. Moving
setExistsOnServer(false) last is observationally free: EntityMetaStore
defaults an unknown key to false. roots.register bumps mutationVersion,
which invalidates the reachability memo, so the subscriber woken by that
last notification recomputes and sees the create.

The undo journal is unaffected: the pre-image is captured inside
setEntityData before any root write in either order, and the per-kind
write guard already fuses snapshot, meta and roots into one `entity`
kind recorded by that same call.

clearAllServerErrors was simply silent where its sibling clearAllErrors
notifies for the same kind of write. Both in-tree callers self-heal by
ordering, so this only ever reached users through the exported action.

Known and unchanged: both siblings clear relation errors under the key
prefix but notify only the entity, so a subscribeToRelation consumer
still sees a stale value after either call.
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