Repository navigation
feat: emit change events for local and remote changes, with corrections marked - #3413
christianhg wants to merge 2 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 25ac2ad 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 (37)🗺️
Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time. |
9a543eb to
736775b
Compare
736775b to
619ff66
Compare
619ff66 to
3f9a9a2
Compare
3f9a9a2 to
c055141
Compare
…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 in `engine/core/change.ts` records every operation the engine
applies for one cause, normalization fixes included, and commits once.
A change opens only at an entry point, which names its cause. The editor
machine's `handle behavior event` opens a `behavior event` change,
`setupRemotePatches` opens `remote patches`, the sync machine's
`syncValueCallback` opens `value sync` with `initial` set for the first
load, and the editor machine's `register`, `unregister` and mount-time
drain open `registration`. `apply` never opens a change. An apply
outside a change, or a `withChange` reached while a change with another
cause is open, throws under test and logs in development
(`assertChangeOpen`). A public selection change goes through the
`select` event, so it opens a `behavior event` change like any other
Behavior event. Operation listeners see every operation live, as it
applies.
`apply` records an entry once the operation completes, at the position
it started at, so a trigger sits ahead of the fixes it set off even
though their `after` listeners run first, and an apply that throws is
never recorded. Patch generation sets each entry's local patches. The
entry kind comes from the cause. Under remote patches and value sync,
an operation applied while patching is a correction and the rest is
remote. Under a registration, patching operations are corrections, so
the fixes a container registered after load makes to existing content
reach the host as corrections. Only a Behavior event reads the
`normalization` frame, because its own operations and the fixes they
trigger interleave in one change.
`toChangeEvents` turns a committed change into `change` events: the
remote operations first, then the local entries in application order,
each run of consecutive edits or consecutive corrections as its own
event. 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 event goes out only when
`isEqualValues` finds the content after the change differs from the
content before its first remote operation, so a remote `set` of a value
the editor already holds emits nothing. The first load's remote
operations are dropped by `cause.initial`, selection operations by
`isPublicOperation`, and the empty-editor placeholder insert by a new
`placeholder` frame. A change is detached before its listeners run, so
a listener that applies again needs a cause of its own. The events of
one change go to the relay in one `sendAll`, which queues them all
before delivering the first, so a change a listener sets off is
delivered after the corrections of the change it reacted to.
`updateValue` is async and a streamed first load syncs its blocks
across ticks. The sync creates its change up front, resumes it for each
tick's work (`resumeChange`, including the selection restore after a
streamed load) and commits it once, before `done syncing` reaches the
sync machine, so a streamed first load is one change and its correction
is delivered before setup ends, buffered remote patches drain and a
pending value starts syncing. Public `editor.send({type: 'update
value'})` goes to the sync actor directly, so a Behavior effect can
start a sync while the Behavior's change is open. `resumeChange` then
publishes what the open change has applied so far as its own change,
sets it aside for the sync and restores it afterwards, so the sync
keeps its own cause and the Behavior's later operations form a third
change.
A read-only editor's deselect in `onDOMSelectionChange` called core
`deselect`, which applied `set.selection` with no cause. It now sends a
`select` Behavior event with `at: null`, like every other DOM selection
sync, so Behaviors listening to `select` see it.
`isPublicOperation` moves from `create-editor.ts` to `types/operation.ts`
so the change events share the `operation` event's allowlist.
`mutation`, `patch` and the batcher are unchanged. Validation
auto-resolutions on intake (`syncBlock` emitting resolution patches
through `sendBack` and applying the repaired block without patching)
are not part of any `change`: they reach the host through `patch` and
`mutation` only. Bare-engine unit
tests wrap their direct engine calls in `inTestChange`.
`event.change.test.tsx` pins one change for a 25-block streamed first
load and the `correction` of a late container registration, and
`selection-readonly-sync.test.tsx` pins the `select` events, the
`selection` events and focus of the read-only deselect.
A "Changes" inspector tab lists every `change` event the editors emit,
newest first. `EditorEventListener` subscribes with `editor.on('change')`
and sends each event to its editor actor, which forwards it to the
playground as `editor.change` with its editor id. The playground keeps
the events in `changeFeed`, only while the `change recording` state is
`recording`, so pausing drops events instead of buffering them.
Each entry shows the origin, a correction badge, and the operation count,
and expands to the operations and, for local changes, the patches,
highlighted with `highlightMachine`. The tab's actions pause, copy and
clear the feed, mirroring the Patches tab.
ab9043c to
25ac2ad
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 25ac2ad. 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 Sanity patches, the same patches thepatchevent emits for the action, 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. Registering or unregistering a node witheditor.registerNodeafter load can make the editor fix existing content, and those fixes are corrections. A host that saves every local change'spatchessaves corrections as well. A correction to received content arrives even when the editor drops it frommutationbecause a newer value or a conflicting remote patch arrived first. A key or unused mark definition the editor repairs while validating anupdate valueis not part of anychange, and still reaches the host only throughpatchandmutation.origin: 'remote': one event perpatchesmessage and perupdate valuethat changed the content. Remote changes carry nopatches.Every
changeof one action is delivered before anychangethat a listener sets off while receiving them. Loading the initial value emits nochange, except acorrectionwhen the editor fixes the loaded value, and achangenever has an emptyoperationsarray. A long initial value that the editor loads across several ticks counts as one load, so its fixes arrive as onecorrection.ChangeEventis exported as a beta type.EditorEmittedEventgains thechangemember, so exhaustive switches overevent.typegain a case.When the DOM selection leaves a read-only editor's content, the editor now deselects through the
selectevent withat: null, so Behaviors listening toselectsee it.Every change opens at its entry point with a cause: a Behavior event, remote patches, a value sync (with the first load marked) or a container registration.
applynever opens one, and an apply outside a change throws under test and logs in development, so a path with no cause shows up instead of being folded into a neighbour. The change records each operation where it started, so a trigger precedes the fixes it set off, and it commits once. Its events go to the relay together, so a change a listener sets off is delivered after the corrections of the change it reacted to. Undo still groups by undo step, and history and patch generation still read operations live. Only thechangeevent is built from the change.Two paths behave differently for existing consumers. The read-only deselect now goes through
selectwithat: null(pinned inselection-readonly-sync.test.tsx). And a Behavior that edits, sendsupdate valuefrom an effect, then edits again now sees its edits as two local changes on either side of the remote change, in the order the engine applied them. Repairs made while validating anupdate valueare left out ofchangeon purpose. They have no applied operation to attach to, and that validation path goes away in the next major.mutation,patch,update valueand the batcher are unchanged. Text patches are still derived from the value after normalization, as they are formutation, so a normalizer that inserts text inside text the same apply just inserted can replay differently.Note
Medium Risk
New beta public API and strict change/commit ordering affect persistence and event listeners; read-only deselect now routes through Behaviors, which can change integration assumptions.
Overview
Adds a new
changeevent (betaChangeEvent) that fires synchronously whenever document content changes, carrying the publicoperationsapplied—not raw inputs. Local events includepatchesimmediately (same aspatch, ahead of debouncedmutation); remote events have operations only. Normalization and fixes after remote loads are separate local events withcorrection: true.The engine now records “changes” at entry points (Behavior events, remote patches, value sync, node registration):
applymust run inside an open change, operations are grouped and committed once, and the relay’s newsendAlldelivers all events from one commit before anychangea listener triggers.Playground gains a Changes inspector tab (feed, pause/resume recording, copy/clear). Read-only editors deselect via a
selectBehavior withat: nullinstead of a direct selection apply, soselectlisteners see it.EditorEmittedEventand exports are updated; existingpatch/mutationbehavior is unchanged aside from test expectations.Reviewed by Cursor Bugbot for commit 25ac2ad. Bugbot is set up for automated code reviews on this repo. Configure here.