Skip to content

feat: emit change events with the applied patches for local and remote changes - #3199

Open
christianhg wants to merge 1 commit into
mainfrom
feat/change-channel
Open

feat: emit change events with the applied patches for local and remote changes#3199
christianhg wants to merge 1 commit into
mainfrom
feat/change-channel

Conversation

@christianhg

@christianhg christianhg commented Aug 31, 2026

Copy link
Copy Markdown
Member

What

The editor's applied changes are only partially observable: the mutation event carries local patches for persistence, and remote changes (fed patches, update value reconciliation) 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:

<EventListenerPlugin
  on={(event) => {
    if (event.type === 'change' && event.origin === 'remote') {
      for (const operation of event.operations) {
        // e.g. mark derived state for the touched blocks stale
      }
    }
  }}
/>

change carries {operations: Array<Operation>, origin: 'local' | 'remote'}. The payload is the already public closed five-variant Operation union, so no new vocabulary enters the surface, and there is no patch conversion between the engine and the consumer. Subscribe to mutation to persist local edits; subscribe to change to 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 withRemoteChanges bracket, which is what makes update value observable at all: the sync machine invents reconciliation operations for which no fed patch exists. A remote update therefore 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 change relative to the seed document; position-folding consumers start at ready, per the JSDoc. The apply-context frames carry each bracket's source internally, so a public source field 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 operationId together, so its internal.patch events carry the operation reference (only for public operations, and never when the operation's apply context carries a remote frame: those repairs the remote change already 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 public MutationEvent is untouched) 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 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.change suite also pins local adjacency to mutation, remote fed patches, update value invented operations, per-block bulk order for a two-block update, silence on identical resupply, and undo reporting local. 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 isCreatingPlaceholder flag became a {kind: 'placeholder'} apply-context frame invisible to getOrigin (pinned at the projection level), the collector's nesting guard reads hasRemoteFrame, 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 change event (ChangeEvent) so consumers can observe what operations were applied to the document, with origin: 'local' | 'remote', without diffing snapshots. mutation stays the local persistence outbox; local edits can appear on both channels.

Local path: the mutation batcher keeps operations beside patches; patch generation attaches the triggering public operation on the first patch of each operation. The actor bridge strips internal fields from mutation, then emits change with a copied operations array when non-empty (paired with mutation at the same cadence).

Remote path: withRemoteChanges collects public operations (excluding placeholder bracket work) and calls editor.onRemoteChange, wired at setup to emit remote change events. Placeholder block insertion is wrapped in a placeholder apply-context frame so structural empty-editor bookkeeping does not show up on the ledger.

Also exports isPublicOperation from types/operation, documents change on EventListenerPlugin, and adds broad event.change coverage (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.

@vercel

vercel Bot commented Aug 31, 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 Sep 4, 2026 6:36pm UTC
portable-text-example-basic Ready Ready Preview Sep 4, 2026 6:36pm UTC
portable-text-playground Ready Ready Preview Sep 4, 2026 6:36pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 25560af

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

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator 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 Aug 31, 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.09 MB / 254.6 KB +385 B, +0.1% 46 ms -0 ms, -0.5%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 1 ms -0 ms, -2.4%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB +4 B, +0.2% 5 ms -0 ms, -1.4%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.7 KB / 21.3 KB None 5 ms -0 ms, -0.5%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.7 KB / 10.8 KB None 4 ms -0 ms, -5.9%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.1 KB / 8.7 KB None 4 ms -1 ms, -12.8%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 26 ms +2 ms, +7.7%

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

Comment thread packages/editor/src/engine-plugins/engine-plugin.remote-changes.ts
Comment thread packages/editor/src/editor/create-editor.ts Outdated
Comment thread packages/editor/src/editor/create-editor.ts Outdated
Comment thread packages/editor/src/editor/create-editor.ts Outdated
Comment thread packages/editor/src/editor/create-editor.ts

@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 4a5f1bb. Configure here.

Comment thread packages/editor/src/engine-plugins/engine-plugin.remote-changes.ts Outdated
…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.
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