feat: emit change events with the applied patches for local and remote changes - #3199
feat: emit change events with the applied patches for local and remote changes#3199christianhg wants to merge 1 commit into
change events with the applied patches for local and remote changes#3199Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 25560af The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Bundle Stats✅ No significant changes. All scenario measurements (7)🗺️
Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time. |
0b21f5e to
0e5fb9a
Compare
0e5fb9a to
0ac2e58
Compare
0ac2e58 to
89f97e0
Compare
89f97e0 to
1f0a14b
Compare
1f0a14b to
c160d59
Compare
a8d36fc to
8866360
Compare
8866360 to
4a5f1bb
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4a5f1bb. Configure here.
4a5f1bb to
cde4e20
Compare
cde4e20 to
f61ac0e
Compare
f61ac0e to
2fc03a2
Compare
2fc03a2 to
59ecae7
Compare
…remote changes
The editor's applied changes were only partially observable: the
`mutation` event is the outbox (local patches to persist), and remote
changes (fed `patches`, `update value` reconciliation) produced no
event at all. The `change` event is the ledger: what was applied to
the document, from any origin, in order. A local edit appears on both,
each serving its own consumers.
The payload is `{operations: Array<Operation>, origin}`: the public
closed five-variant `Operation` union, so no new vocabulary enters the
surface, and no patch conversion sits between the engine and the
consumer. The event reports what was applied, never what was received.
Remote bulks are collected per `withRemoteChanges` bracket (try/finally
so a throwing application cannot leak the subscription); deriving from
applied operations is what covers `update value`, where the sync
machine invents reconciliation operations and no fed patch exists. The
sync machine applies value updates block by block, so a remote update
emits one or more `change` events in application order, deliberately
not coalesced: the streamed sync path yields between brackets and local
flushes can interleave. The initial value sync emits a remote `change`
relative to the seed document; consumers folding stored positions start
at `ready`, documented on the event. The apply-context frames carry the
bracket's source internally, so exposing it on the event later is one
additive field if a consumer needs more than the gate.
Local bulks are built where the association already exists instead of
reconstructed downstream: `subscribePatchGeneration` holds operation,
patches, and `operationId` together, so its `internal.patch` events
carry the operation reference (once per operation, only for public
operations, and never when the event's context carries a remote frame:
those repairs the remote `change` already reports while their patches
keep the outbox unchanged). The mutation batcher accumulates the references
beside the patches in the bulks it already maintains, and its flushed
internal mutation event carries them to the bridge, which strips the
internal fields and emits the `change` with a copied array when
non-empty. The bulk IS the mutation: flush retirement, discard-wipe,
pairing, and id reuse are correct by construction, with no dependence
on operation-channel subscription order or actor mailbox re-entrancy.
One deliberate consequence, documented on the event: local bulks
contain the operations whose application produced outgoing patches;
remote bulks contain all applied public operations.
The `event.change` suite pins the contract and the failure modes that
shaped the design: local adjacency to the `mutation` flush, remote fed
patches, `update value` invented operations, per-block bulk count and
order, silence on identical resupply, undo reporting `local`, two
consecutive flushes reporting disjoint bulks, auto-resolution mutations
emitting and stealing nothing, remote repairs reported only remotely,
a remote emptying reporting no placeholder `insert` (the placeholder
insert pushes a `placeholder` apply-context frame, invisible to
`getOrigin`, and the remote collector skips it: red on the unflagged bracket), an
undefined/defined/undefined id interleave keeping every
`mutation`-`change` pair, and a direct `editorEngine.apply()` bypass
outside actor processing.
59ecae7 to
25560af
Compare

What
The editor's applied changes are only partially observable: the
mutationevent carries local patches for persistence, and remote changes (fedpatches,update valuereconciliation) produce no event at all. Anything tracking positions or observing the document has to reverse-engineer what happened from value snapshots. This PR adds the ledger to the outbox:changecarries{operations: Array<Operation>, origin: 'local' | 'remote'}. The payload is the already public closed five-variantOperationunion, so no new vocabulary enters the surface, and there is no patch conversion between the engine and the consumer. Subscribe tomutationto persist local edits; subscribe tochangeto observe everything applied to the document, in order. A local edit appears on both, each serving its own consumers.The event reports applied changes, never received inputs. Remote bulks are collected per
withRemoteChangesbracket, which is what makesupdate valueobservable at all: the sync machine invents reconciliation operations for which no fed patch exists. A remote update therefore emits one or morechangeevents in application order, deliberately not coalesced (the streamed sync path yields between brackets and local flushes can interleave). The initial value sync emits achangerelative to the seed document; position-folding consumers start atready, per the JSDoc. The apply-context frames carry each bracket's source internally, so a publicsourcefield stays one additive step away if a consumer ever needs more than the gate: deliberately not shipped now, since the only articulated need is the hydration skip the gate already serves, and the input taxonomy (patches/update-value/initial-sync) is exactly what later protocol work would want to reshape.Local bulks are built where the association already exists instead of reconstructed downstream: the patch-generation subscriber holds operation, patches, and
operationIdtogether, so itsinternal.patchevents carry the operation reference (only for public operations, and never when the operation's apply context carries a remote frame: those repairs the remotechangealready reports while their patches keep the outbox unchanged). The mutation batcher accumulates references beside the patches in the bulks it already maintains, and the flushed mutation carries them to the bridge, which strips the internal fields (the publicMutationEventis untouched) and emits thechangewith a copied array when non-empty. The bulk IS the mutation: flush retirement, discard-wipe, pairing, and id reuse are correct by construction, with no dependence on subscription order or actor mailbox re-entrancy. An earlier design reconstructed the association at the relay with a collector and an id-join; review found five failure modes in it (three by machine review), each reproduced and pinned, and those pins now guard the simpler construction, which passed all of them unmodified. One deliberate delta, documented on the event: local bulks contain the operations whose application produced outgoing patches; remote bulks contain all applied public operations.The
event.changesuite also pins local adjacency tomutation, remote fed patches,update valueinvented operations, per-block bulk order for a two-block update, silence on identical resupply, and undo reportinglocal. The motivating consumers are position mapping (a steps recognizer folding bulks over stored anchors) and change observation without snapshot diffing; both consume the event as-is.Rebased onto the apply-context refactor (#3227, #3228, #3229): the branch's
isCreatingPlaceholderflag became a{kind: 'placeholder'}apply-context frame invisible togetOrigin(pinned at the projection level), the collector's nesting guard readshasRemoteFrame, and the local suppression reads the event's context snapshot.Note
Medium Risk
Touches core edit/sync/mutation pipelines and public event surface; behavior is heavily tested but ordering and local-vs-remote semantics are easy for integrators to misuse without reading the
ready/hydration notes.Overview
Adds a public
changeevent (ChangeEvent) so consumers can observe what operations were applied to the document, withorigin: 'local' | 'remote', without diffing snapshots.mutationstays the local persistence outbox; local edits can appear on both channels.Local path: the mutation batcher keeps
operationsbeside patches; patch generation attaches the triggering public operation on the first patch of each operation. The actor bridge strips internal fields frommutation, then emitschangewith a copied operations array when non-empty (paired withmutationat the same cadence).Remote path:
withRemoteChangescollects public operations (excludingplaceholderbracket work) and callseditor.onRemoteChange, wired at setup to emit remotechangeevents. Placeholder block insertion is wrapped in aplaceholderapply-context frame so structural empty-editor bookkeeping does not show up on the ledger.Also exports
isPublicOperationfromtypes/operation, documentschangeonEventListenerPlugin, and adds broadevent.changecoverage (sync, patches, undo, placeholder, repair-only mutations).Reviewed by Cursor Bugbot for commit 25560af. Bugbot is set up for automated code reviews on this repo. Configure here.