Skip to content

entity:persisting / entity:persisted events never emitted (silent no-op) #28

@jonasnobile

Description

@jonasnobile

Summary

EntityHandle.intercept('entity:persisting', …) and useOnEntityEvent('entity:persisted', …) (and the related interceptPersisting() / onPersisted() helpers) compile, register listeners with EventEmitter without warnings, and never fire at runtime when the entity is persisted.

The useOnEntityEvent hook's own JSDoc literally documents the persisted variant as the canonical example:

// packages/bindx-react/src/hooks/useBindxEvents.ts:53
useOnEntityEvent('entity:persisted', 'Article', articleId, (event) => {
  toast.success('Article saved!')
})

…but the toast never fires.

Root cause

BatchPersister dispatches setPersisting(entity, isPersisting) (action type SET_PERSISTING) before and after each persist (packages/bindx/src/persistence/BatchPersister.ts:211, 265). The dispatch flows through ActionDispatcher, which calls events/eventFactory.ts → createBeforeEvent / createAfterEvent.

Both factory switch statements have no case for SET_PERSISTING (packages/bindx/src/events/eventFactory.ts:50, 221), so they return null, so the EventEmitter never gets a entity:persisting / entity:persisted event to fan out to subscribers.

Same gap exists for DELETE_ENTITY action → entity:deleting / entity:deleted events.

Only entity:resetting / entity:reset actually have the matching factory case today.

Reproducer (failing tests)

PR with two failing tests: #N (link added when PR is opened against this issue).

Both tests register the canonical advertised usage, trigger $persist(), observe that the mutation succeeds (the mock store reflects the new value), and assert the interceptor / listener was called. They currently fail with Received length: 0.

(fail) entity:persisting interceptor fires before BatchPersister sends mutations
(fail) entity:persisted listener fires after a successful persist

Suggested fix

Two possible paths, either acceptable:

A) Extend the eventFactory switches to cover SET_PERSISTING (and DELETE_ENTITY):

// createBeforeEvent
case 'SET_PERSISTING': {
  if (!action.isPersisting) return null  // only fire on isPersisting:true
  const isNew = store.isUnpersisted(action.entityType, action.entityId)
  return { type: 'entity:persisting', timestamp, entityType: action.entityType, entityId: action.entityId, isNew } satisfies EntityPersistingEvent
}

// createAfterEvent — only the success path; failure path lives in BatchPersister catch
case 'SET_PERSISTING': {
  if (action.isPersisting) return null  // before-only; the false case is the after
  ...
}

The challenge: EntityPersistedEvent carries persistedId: string which is only known after the server response (for new entities). The SET_PERSISTING(false) action doesn't carry it.

B) Emit the lifecycle events directly from BatchPersister (and DeleteEntityRunner / wherever delete lives), bypassing the action-factory mapping for these higher-level events. This is the cleaner shape long-term — the action-factory mapping is appropriate for simple state changes (field changed) but persist lifecycle is multi-step and needs richer payload than the action carries.

I'd lean BBatchPersister already has all the information (entity.entityType, entity.entityId, entity.changeType === 'create', transactionResult.persistedIds, the caught error for the failure path) at the right callsites. Add dispatcher.getEventEmitter().emit({ type: 'entity:persisted', … }) at the end of processTransactionResult per entity, and entity:persistFailed in the catch.

Happy to follow up with the implementation if option B is preferred — would rather not guess at the persistedId-derivation in option A and submit a wrong patch.

Impact

Any consumer that relies on these events as documented:

  • "Before save" hooks for normalization (e.g. populating cached search columns from the original fields right before the mutation goes out)
  • "After save" toasts / cache invalidation
  • Delete confirmation interceptors
  • Audit-log emissions tied to persist boundaries

…silently does nothing. The bug compiles, runs without warnings, and shows up only when the developer notices the side-effect never happened.

We hit this in a downstream project where a useEntityBeforePersist-style normalization hook was a no-op in production for several releases — only caught when reviewing why a search index had stale rows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions