Repository navigation
feat: emit change events for local and remote changes, with corrections marked - #3413
christianhg wants to merge 1 commit into
Conversation
…ions marked Hosts had two ways to learn what the editor did to its content: the per-operation `operation` feed, which has no notion of a user action and no patches, and `mutation`, which carries patches but only on the batcher's debounced flush and folds the editor's own normalization fixes into the user's edit. Neither says "this action did these operations, as these patches" at the moment the action finishes. A change collector on the engine now groups applied operations into `change` events. It records an entry for every operation when its apply starts, since the `after` listeners of normalization fixes run before the one of the operation that triggered them, and fills the entry with the public operation and the local patches patch generation hands it. Entries split by their apply-context frames: a `normalization` frame while patching is a correction, a `remote` frame is remote, everything else is an edit. The collector flushes when the outermost unit of work returns: the depth-0 `behavior event` the editor machine performs, a whole value sync (the sync machine's `syncValueCallback`), the outermost `withRemoteChanges` bracket, or, for an apply outside all of them, the outermost apply. A flush emits the unit's remote change first, then its local entries in application order, each run of consecutive edits or consecutive corrections as its own change. A Behavior event whose action groups normalize in between therefore emits edit, correction, edit, and replaying the patches in that order gives the editor's value. A remote change goes out only when `isEqualValues` finds the content after the unit differs from the content before its first remote operation, so a remote `set` of a value the editor already holds emits nothing. Entries are detached before any event is emitted, so a listener that applies again starts a new change instead of adding to one already delivered. Unit depth is tracked with try/finally, apart from the apply depth the operation listeners count. An apply that throws leaves the apply depth raised; the outermost unit resets it on exit, and so does the next apply that can only be outermost (no open unit and no `normalization` frame, since only normalization re-enters `apply`). Remote operations under the `initial-sync` source are dropped, so the first load emits nothing, and the empty-editor placeholder insert runs under a new `placeholder` frame and is never reported. `isPublicOperation` moves from `create-editor.ts` to `types/operation.ts` so the collector shares the `operation` event's allowlist. `mutation`, `patch` and the batcher are unchanged.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 9a543eb The changes in this PR will be included in the next version bump. This PR includes changesets to release 15 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. |
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 9a543eb. Configure here.
| !(entry.kind === 'remote' && isInitialSync(event.context)) | ||
| ) { | ||
| entry.operation = event.operation | ||
| } |
There was a problem hiding this comment.
First load can emit change events
Medium Severity
The initial-sync skip only drops kind === 'remote' entries, so a first-load fix (duplicate _key, span merge) is recorded as a local correction and still emitted. That breaks the contract that loading the initial value emits no change. Hosts that persist every local patches array then write on first paint.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 9a543eb. Configure here.


The editor now emits a
changeevent, synchronously, every time its content changes. Each event carries theoperationsthe editor applied to its content (the publicOperationtype), never the input it received:origin: 'local': what a user action did, including everything the action's Behaviors raised. Undo and redo are one action each. Local changes arrive in application order, and one user action can produce several when the editor fixes something mid-action.patchesholds the change as Content Lake patches, the same patches itsmutationcarries, available as soon as the action finishes instead of on themutationevent's debounced schedule.origin: 'local'withcorrection: true: the editor's own fixes to the content, such as merging adjacent spans that share marks after a paste, or replacing a duplicate_key. A correction follows the change that caused it, with its own operations and patches. Fixes to content the editor received throughpatchesorupdate valueare corrections too. A host that saves every local change'spatchessaves corrections as well.origin: 'remote': one event perpatchesmessage and perupdate valuethat changed the content. Remote changes carry nopatches.Loading the initial value emits no
change, and achangenever has an emptyoperationsarray.ChangeEventis exported as a beta type.EditorEmittedEventgains thechangemember, so exhaustive switches overevent.typegain a case.A change collector on the engine groups applied operations into units of work: a performed Behavior event, a whole
update value, awithRemoteChangesbracket, or a lone apply outside all of them. It records each operation when its apply starts, because the editor finishes a fix before the edit that triggered it, and splits entries by their apply-context frame into edits, corrections and remote operations. A Behavior event whose action groups normalize in between therefore emits edit, correction, edit, and replaying those patches in order gives the editor's value. That case is pinned inevent.change.test.tsx, which failed on the collector that grouped by kind.mutation,patchand the batcher are unchanged here. Removing them is a separate change. Text patches are still derived from the value after normalization, as they are formutationtoday, so a normalizer that inserts text inside text the same apply just inserted can replay differently.Note
Medium Risk
New synchronous persistence hook on every content mutation; grouping and correction ordering affect hosts that save on
change, though behavior is heavily tested.Overview
Adds a new
changeevent (beta) that fires synchronously whenever document content changes, carrying the applied publicOperationlist—not the raw input.Local changes group one user action (behavior event, undo/redo) via
withChangeUnit; they include Content Lakepatchesimmediately (same data as debouncedmutation). Corrections (correction: true) follow the edit that caused normalization fixes (merge spans, duplicate_key, etc.) with their own operations and patches. Remote changes emit once perpatchesorupdate valuethat actually changed content, with operations only (no patches). Initial load emits nochange.A
createChangeCollectorsubscribes to the operation channel, classifies applies by apply-context (edit / correction / remote), attaches patches from patch generation, and flushes at unit boundaries.EditorEmittedEventandeditor.on('change')are wired through the relay;ChangeEventis exported.Reviewed by Cursor Bugbot for commit 9a543eb. Bugbot is set up for automated code reviews on this repo. Configure here.