Skip to content

feat: emit change events for local and remote changes, with corrections marked - #3413

Open
christianhg wants to merge 2 commits into
mainfrom
feat/change-event
Open

christianhg wants to merge 2 commits into
mainfrom
feat/change-event

Conversation

@christianhg

@christianhg christianhg commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

The editor now emits a change event, synchronously, every time its content changes. Each event carries the operations the editor applied to its content (the public Operation type), never the input it received:

editor.on('change', (event) => {
  if (event.origin === 'local') {
    // `event.patches` is the change as Sanity patches.
    save(event.patches)
  }
})
  • 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. patches holds the change as Sanity patches, the same patches the patch event emits for the action, available as soon as the action finishes instead of on the mutation event's debounced schedule.
  • origin: 'local' with correction: 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 through patches or update value are corrections too. Registering or unregistering a node with editor.registerNode after load can make the editor fix existing content, and those fixes are corrections. A host that saves every local change's patches saves corrections as well. A correction to received content arrives even when the editor drops it from mutation because a newer value or a conflicting remote patch arrived first. A key or unused mark definition the editor repairs while validating an update value is not part of any change, and still reaches the host only through patch and mutation.
  • origin: 'remote': one event per patches message and per update value that changed the content. Remote changes carry no patches.

Every change of one action is delivered before any change that a listener sets off while receiving them. Loading the initial value emits no change, except a correction when the editor fixes the loaded value, and a change never has an empty operations array. A long initial value that the editor loads across several ticks counts as one load, so its fixes arrive as one correction. ChangeEvent is exported as a beta type. EditorEmittedEvent gains the change member, so exhaustive switches over event.type gain a case.

When the DOM selection leaves a read-only editor's content, the editor now deselects through the select event with at: null, so Behaviors listening to select see 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. apply never 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 the change event is built from the change.

Two paths behave differently for existing consumers. The read-only deselect now goes through select with at: null (pinned in selection-readonly-sync.test.tsx). And a Behavior that edits, sends update value from 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 an update value are left out of change on purpose. They have no applied operation to attach to, and that validation path goes away in the next major.

mutation, patch, update value and the batcher are unchanged. Text patches are still derived from the value after normalization, as they are for mutation, 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 change event (beta ChangeEvent) that fires synchronously whenever document content changes, carrying the public operations applied—not raw inputs. Local events include patches immediately (same as patch, ahead of debounced mutation); remote events have operations only. Normalization and fixes after remote loads are separate local events with correction: true.

The engine now records “changes” at entry points (Behavior events, remote patches, value sync, node registration): apply must run inside an open change, operations are grouped and committed once, and the relay’s new sendAll delivers all events from one commit before any change a listener triggers.

Playground gains a Changes inspector tab (feed, pause/resume recording, copy/clear). Read-only editors deselect via a select Behavior with at: null instead of a direct selection apply, so select listeners see it. EditorEmittedEvent and exports are updated; existing patch / mutation behavior 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.

@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
portable-text-editor-documentation Ready Ready Preview Oct 30, 2026 6:59pm UTC
portable-text-example-basic Ready Ready Preview Oct 30, 2026 6:59pm UTC
portable-text-playground Ready Ready Preview Oct 30, 2026 6:59pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 25ac2ad

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-decorations Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

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

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

All scenario measurements (37)

🗺️ @portabletext/block-tools / @portabletext/block-tools · @portabletext/block-tools / @portabletext/block-tools/rules · @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/editor / defineSchema · @portabletext/editor / useEditor · @portabletext/editor / typical · @portabletext/html / @portabletext/html · @portabletext/html / @portabletext/html/rules · @portabletext/html / htmlToPortableText · @portabletext/keyboard-shortcuts / @portabletext/keyboard-shortcuts · @portabletext/markdown / @portabletext/markdown · @portabletext/markdown / portableTextToMarkdown · @portabletext/patches / @portabletext/patches · @portabletext/repair / @portabletext/repair · @portabletext/sanity-bridge / @portabletext/sanity-bridge · @portabletext/schema / @portabletext/schema · @portabletext/plugin-character-pair-decorator / @portabletext/plugin-character-pair-decorator · @portabletext/plugin-decorations / @portabletext/plugin-decorations · @portabletext/plugin-dnd / @portabletext/plugin-dnd · @portabletext/plugin-emoji-picker / @portabletext/plugin-emoji-picker · @portabletext/plugin-input-rule / @portabletext/plugin-input-rule · @portabletext/plugin-list-index / @portabletext/plugin-list-index · @portabletext/plugin-markdown-shortcuts / @portabletext/plugin-markdown-shortcuts · @portabletext/plugin-one-line / @portabletext/plugin-one-line · @portabletext/plugin-paste-link / @portabletext/plugin-paste-link · @portabletext/plugin-sdk-value / @portabletext/plugin-sdk-value · @portabletext/plugin-typeahead-picker / @portabletext/plugin-typeahead-picker · @portabletext/plugin-typography / @portabletext/plugin-typography · @portabletext/toolbar / @portabletext/toolbar · @portabletext/toolbar / typical · @portabletext/plugin-table / @portabletext/plugin-table · @portabletext/plugin-table / @portabletext/plugin-table/ui · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/block-tools / @portabletext/block-tools export 338.3 KB / 40.0 KB None 142 ms -0 ms, -0.3%
⚪ @portabletext/block-tools / @portabletext/block-tools/rules export 15.2 KB / 2.1 KB None 4 ms +0 ms, +1.9%
⚪ @portabletext/editor / @portabletext/editor export 1.03 MB / 131.0 KB +981 B, +0.7% 54 ms -0 ms, -0.2%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 147 B None 2 ms -0 ms, -2.4%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 645 B None 6 ms -0 ms, -2.5%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.5 KB / 9.2 KB None 7 ms +0 ms, +2.5%
⚪ @portabletext/editor / @portabletext/editor/traversal export 43.5 KB / 4.0 KB None 5 ms +0 ms, +2.9%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.1 KB / 4.1 KB None 5 ms -0 ms, -0.6%
⚪ @portabletext/editor / defineSchema consumer 658 B / 69 B None None None
⚪ @portabletext/editor / typical consumer 1.02 MB / 130.8 KB +994 B, +0.7% None None
⚪ @portabletext/editor / useEditor consumer 1.6 KB / 248 B None None None
⚪ @portabletext/html / @portabletext/html export 69.3 KB / 10.1 KB None 6 ms -0 ms, -0.1%
⚪ @portabletext/html / @portabletext/html/rules export 15.2 KB / 2.1 KB None 3 ms -0 ms, -0.0%
⚪ @portabletext/html / htmlToPortableText consumer 69.3 KB / 10.1 KB None None None
⚪ @portabletext/keyboard-shortcuts / @portabletext/keyboard-shortcuts export 7.0 KB / 858 B None 2 ms +0 ms, +1.7%
⚪ @portabletext/markdown / @portabletext/markdown export 424.8 KB / 66.1 KB None 29 ms -0 ms, -1.5%
⚪ @portabletext/markdown / portableTextToMarkdown consumer 338.3 KB / 56.2 KB None None None
⚪ @portabletext/patches / @portabletext/patches export 50.0 KB / 7.7 KB None 3 ms -0 ms, -0.1%
⚪ @portabletext/plugin-character-pair-decorator / @portabletext/plugin-character-pair-decorator export 123.9 KB / 15.8 KB None None None
⚪ @portabletext/plugin-decorations / @portabletext/plugin-decorations export 16.4 KB / 2.3 KB None None None
⚪ @portabletext/plugin-dnd / @portabletext/plugin-dnd export 10.4 KB / 1.8 KB None None None
⚪ @portabletext/plugin-emoji-picker / @portabletext/plugin-emoji-picker export 149.8 KB / 18.5 KB None None None
⚪ @portabletext/plugin-input-rule / @portabletext/plugin-input-rule export 124.0 KB / 15.7 KB None None None
⚪ @portabletext/plugin-list-index / @portabletext/plugin-list-index export 7.4 KB / 1.1 KB None None None
⚪ @portabletext/plugin-markdown-shortcuts / @portabletext/plugin-markdown-shortcuts export 136.2 KB / 17.6 KB None None None
⚪ @portabletext/plugin-one-line / @portabletext/plugin-one-line export 2.6 KB / 608 B None None None
⚪ @portabletext/plugin-paste-link / @portabletext/plugin-paste-link export 4.3 KB / 1.0 KB None None None
⚪ @portabletext/plugin-sdk-value / @portabletext/plugin-sdk-value export 242.1 KB / 32.8 KB None None None
⚪ @portabletext/plugin-table / @portabletext/plugin-table export 61.0 KB / 7.1 KB None None None
⚪ @portabletext/plugin-table / @portabletext/plugin-table/ui export 114.6 KB / 19.5 KB None None None
⚪ @portabletext/plugin-typeahead-picker / @portabletext/plugin-typeahead-picker export 165.9 KB / 19.9 KB None None None
⚪ @portabletext/plugin-typography / @portabletext/plugin-typography export 130.8 KB / 17.0 KB None None None
⚪ @portabletext/repair / @portabletext/repair export 4.5 KB / 636 B None 2 ms -0 ms, -4.3%
⚪ @portabletext/sanity-bridge / @portabletext/sanity-bridge export 275.0 KB / 30.2 KB None 139 ms -0 ms, -0.3%
⚪ @portabletext/schema / @portabletext/schema export 9.9 KB / 1.2 KB None 1 ms -0 ms, -0.4%
⚪ @portabletext/toolbar / @portabletext/toolbar export 153.3 KB / 17.3 KB None None None
⚪ @portabletext/toolbar / typical consumer 145.6 KB / 16.9 KB None None None

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@christianhg
christianhg changed the base branch from next to main October 27, 2026 16:46
@vercel
vercel Bot temporarily deployed to Preview – portable-text-editor-documentation October 30, 2026 11:01 Inactive
…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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

This branch was successfully deployed

3 active deployments
Preview – portable-text-editor-documentation — 25ac2ad3 Deployed Oct 30, 2026 by vercel[bot]
Preview – portable-text-playground — 25ac2ad3 Deployed Oct 30, 2026 by vercel[bot]
Preview – portable-text-example-basic — 25ac2ad3 Deployed Oct 30, 2026 by vercel[bot]
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