Skip to content

fix: flush mutations one acknowledged batch at a time for hosts that echo patches - #3277

Closed
christianhg wants to merge 2 commits into
mainfrom
mutation-ack-pipeline
Closed

christianhg wants to merge 2 commits into
mainfrom
mutation-ack-pipeline

Conversation

@christianhg

@christianhg christianhg commented Sep 15, 2026

Copy link
Copy Markdown
Member

When a host feeds the editor's own patches back through the patches event (tagged origin: 'local', the way a document patch stream does), the editor now treats those echoes as delivery confirmations: the next mutation event waits until every patch of the previous one has come back. A batch whose echoes never arrive is sent anyway after 5 seconds, with a console warning naming the unacknowledged patches, so a broken echo loop slows saving down and shows up in the console rather than stopping saves silently. Unmounting delivers a waiting batch immediately. Hosts that never feed patches back are unaffected and keep the existing cadence.

The pipeline was fire-and-forget: the batcher flushed on a cadence and nothing ever learned whether the host applied a flush, so every downstream decision about pending work was a guess. The evidence was already arriving and being discarded, since the remote-patches subscriber filtered origin: 'local' echoes out unread. The first commit records every flushed batch's patches in an in-flight ledger and routes those echoes into it as acknowledgments. Matching is structural because no transaction identity crosses the mutation boundary, and it assumes hosts echo in application order: an acknowledgment also drops every older record, so a host that starts echoing mid-session activates cleanly instead of stalling its first gated flush on pre-echo backlog, and the backlog is capped at 500 records so a never-echoing host cannot grow it for the life of the session. The second commit gates the flush on the ledger: one batch in flight at a time, released eagerly the moment the backlog drains.

Activation is evidence-based and one-way: the gate turns on at the first successful acknowledgment, which proves the echo loop works end-to-end, so a snapshot-only host, or one that rewrites patch shapes in transit, never activates it and keeps today's behavior (pinned by the echo-less cadence test). Only mutation delivery is paced: deferred patch events relay ahead of the gate, pinned by a read-only-then-gated scenario. The other gated states each carry their own pin: held while unacknowledged with eager release on the drain, send-anyway after the timeout with the warning and the stale ledger records dropped, mid-session activation without a stall, and delivery on unsubscribe since no later tick exists. An end-to-end test drives an echoing host through the public surface: type, echo, type, withhold the echo, observe the hold, echo, observe the release.

This is the first slice of moving mutation delivery from heuristics to acknowledgments; gating decisions about superseded repairs onto the same ledger is the follow-up, and reconciling with #3246's read-only hold happens when that branch rebases.

@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8fa70fe

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 Patch
@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

@vercel

vercel Bot commented Sep 15, 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 15, 2026 10:39am UTC
portable-text-example-basic Ready Ready Preview Sep 15, 2026 10:39am UTC
portable-text-playground Ready Ready Preview Sep 15, 2026 10:39am UTC

Request Review

@github-actions

github-actions Bot commented Sep 15, 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.10 MB / 255.2 KB +838 B, +0.3% 69 ms +1 ms, +1.1%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms -0 ms, -1.3%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms +0 ms, +1.6%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 21.7 KB None 8 ms +0 ms, +2.5%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 6 ms +0 ms, +2.4%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 9.1 KB None 6 ms +0 ms, +0.2%
⚪ @portabletext/markdown / @portabletext/markdown export 381.3 KB / 108.2 KB None 45 ms -0 ms, -0.1%

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

@christianhg
christianhg marked this pull request as ready for review September 15, 2026 08:16

@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 869e188. Configure here.

Comment thread packages/editor/src/editor/mutation-batcher.ts
…wledged by host echoes

The mutation pipeline was fire-and-forget: the batcher emitted
`mutation` events and nothing recorded whether the host ever applied
them. The evidence that it did was already arriving and being thrown
away: hosts wired to a document stream echo every applied patch back
into the `patches` event, tagging this editor's own as
`origin: 'local'`, and `setupRemotePatches` filtered those out unread.

`createMutationLedger` records each flushed bulk's patches at the
moment the batcher emits the `mutation`, and the remote-patches
subscriber now routes `origin: 'local'` echoes into
`ledger.acknowledge` before filtering them out of the apply path as
before. No transaction identity crosses the `mutation` boundary, so an
echo acknowledges the earliest recorded patch it structurally equals,
ignoring `origin`, and drops every record older than the match: hosts
echo in application order, so older echoes either already arrived or
are never coming (keeping them would otherwise stall the first gated
flush of a host that starts echoing mid-session). Unmatched echoes
(emitted before this session, or reshaped by the host in transit) are
reported unmatched and change nothing. The backlog is capped at 500
records, oldest dropped first: a snapshot-only host never drains it,
and only recent emissions can prove an echo channel exists.
`unacknowledged()` exposes the backlog, oldest first, via
`editorEngine.mutationLedger`.

Net behavior unchanged: the ledger only observes. Gating pending
flushes and superseded-repair decisions on the unacknowledged backlog
is layered on separately.
…echo patches

The batcher flushed every pending bulk on each cadence tick regardless
of whether the host had applied the previous flush, so consecutive
mutations could interleave with inbound state computed before their
predecessors landed. The `mutationLedger` now gates the flush: once a
host has proven it echoes the editor's own patches back (the first
successful ledger acknowledgment, and never before, so snapshot-only
hosts keep the fire-and-forget cadence untouched), the next batch
holds until every patch of the in-flight one is echoed back. The
drain flushes eagerly via the ledger's `onAcknowledge` hook; a
backlog older than `ACK_TIMEOUT` (5s, 3s in test mode) is cleared and
the gate degrades to send-anyway, so a patch dropped or rewritten in
transit slows saving down rather than stopping it. The unsubscribe
flush ignores the gate: there is no later tick to deliver on, so a
gated batch is sent rather than lost.

Activation is deliberately evidence-based: a structural match of an
emitted patch is proof the echo loop works end-to-end, so a host that
rewrites patch shapes in transit never activates the gate and loses
nothing. Deferred-while-read-only patch events keep flushing ahead of
the gate check; only `mutation` delivery is paced.

This branch was successfully deployed

3 active deployments
Preview – portable-text-editor-documentation 8fa70fec Deployed Sep 15, 2026 by vercel[bot]
Preview – portable-text-playground 8fa70fec Deployed Sep 15, 2026 by vercel[bot]
Preview – portable-text-example-basic 8fa70fec Deployed Sep 15, 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