Skip to content

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

Open
christianhg wants to merge 1 commit into
nextfrom
feat/change-event
Open

christianhg wants to merge 1 commit into
nextfrom
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 Content Lake 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 Content Lake patches, the same patches its mutation carries, 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. A host that saves every local change's patches saves corrections as well.
  • origin: 'remote': one event per patches message and per update value that changed the content. Remote changes carry no patches.

Loading the initial value emits no change, and a change never has an empty operations array. ChangeEvent is exported as a beta type. EditorEmittedEvent gains the change member, so exhaustive switches over event.type gain a case.

A change collector on the engine groups applied operations into units of work: a performed Behavior event, a whole update value, a withRemoteChanges bracket, 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 in event.change.test.tsx, which failed on the collector that grouped by kind.

mutation, patch and the batcher are unchanged here. Removing them is a separate change. Text patches are still derived from the value after normalization, as they are for mutation today, 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 change event (beta) that fires synchronously whenever document content changes, carrying the applied public Operation list—not the raw input.

Local changes group one user action (behavior event, undo/redo) via withChangeUnit; they include Content Lake patches immediately (same data as debounced mutation). 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 per patches or update value that actually changed content, with operations only (no patches). Initial load emits no change.

A createChangeCollector subscribes to the operation channel, classifies applies by apply-context (edit / correction / remote), attaches patches from patch generation, and flushes at unit boundaries. EditorEmittedEvent and editor.on('change') are wired through the relay; ChangeEvent is exported.

Reviewed by Cursor Bugbot for commit 9a543eb. Bugbot is set up for automated code reviews on this repo. Configure here.

…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.
@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 21, 2026 12:00pm UTC
portable-text-example-basic Ready Ready Preview Oct 21, 2026 12:00pm UTC
portable-text-playground Ready Ready Preview Oct 21, 2026 12:00pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Oct 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9a543eb

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 (7)

🗺️ @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/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/editor / @portabletext/editor export 1.08 MB / 137.2 KB +588 B, +0.4% 56 ms +1 ms, +1.4%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 147 B None 2 ms -0 ms, -1.2%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 645 B None 6 ms -0 ms, -2.1%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 9.2 KB None 7 ms -0 ms, -3.1%
⚪ @portabletext/editor / @portabletext/editor/traversal export 43.6 KB / 4.0 KB None 5 ms -0 ms, -2.0%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 4.1 KB None 5 ms +0 ms, +0.6%
⚪ @portabletext/markdown / @portabletext/markdown export 394.8 KB / 62.7 KB None 22 ms +0 ms, +0.4%

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.

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 9a543eb. Configure here.

!(entry.kind === 'remote' && isInitialSync(event.context))
) {
entry.operation = event.operation
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9a543eb. Configure here.

This branch was successfully deployed

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