Skip to content

fix: emit structural repair patches immediately and make engine normalization the sole repairer - #3246

Open
christianhg wants to merge 2 commits into
mainfrom
emit-repair-patches-immediately
Open

fix: emit structural repair patches immediately and make engine normalization the sole repairer#3246
christianhg wants to merge 2 commits into
mainfrom
emit-repair-patches-immediately

Conversation

@christianhg

@christianhg christianhg commented Sep 8, 2026

Copy link
Copy Markdown
Member

Opening a document whose value contains structurally invalid content (blocks or children without a _key, missing or empty children arrays, duplicate keys) now emits the fixing patches as soon as the value settles. Previously they were held back until the first local edit. Read-only editors emit them too: individual patches relay immediately regardless of read-only state, since a host mirroring them for display has nothing to reject. Mutations, the debounced batches hosts persist, wait for the editor to become editable before delivering, because a host following the documented onChange contract rejects mutations against a read-only document; a held mutation that a newer snapshot supersedes is dropped instead of delivered late, and pending mutations are handed over on unmount instead of dropped, so an edit typed just before a read-only flip and unmount is never lost. A block missing its _key is repaired like any other mechanical defect instead of triggering the invalid-value flow, which now only fires for defects that need a human, with resolution paths anchored at the defective block's actual position.

The repair patches take the editor's own shapes: a minted _key is a minimal set on the _key field, an empty text block gets its placeholder span as an insert before children[0], and all repair patches carry origin: 'local'. Orphaned markDefs are no longer pruned when a value enters the editor; they are pruned when a local edit next touches the block, as a set of the filtered markDefs array. InvalidValueResolution.autoResolve is deprecated and never set.

Accepted behavior, recorded on the tracking tickets: a snapshot echoing a still-keyless copy of an already-repaired block re-mints on the next sync (whole-value sync has no key to correlate an echo by), and concurrent openers of the same malformed document each emit their own repair, converging by last write for key mints.


Note

High Risk
Changes core value sync, patch/mutation delivery, read-only/unmount flushing, and invalid-value vs normalization boundaries—areas that affect persistence, collaboration, and host onChange contracts.

Overview
Structural repairs (missing _key, empty children, duplicate keys, etc.) now emit patch events immediately when a value settles—including on open and in read-only mode—instead of waiting for the first local edit. Engine normalization is the only path that applies those fixes; validateValue no longer auto-resolves mechanical defects, and InvalidValueResolution.autoResolve is deprecated. Human-facing invalid value is reserved for problems that need a user, with resolution patches anchored by nodeSegment (key or document index).

Mutation batching is split from patches: patches always relay at once; debounced mutation events stay held while read-only (and flush on editable or unmount, including read-only teardown). A generation-based cull drops stale repair bulks after inbound sync or applied remote patches, wired via notifyInboundStateApplied and inbound state applied from the sync machine. isDeferringMutations only reflects user edits worth protecting from snapshot clobber.

The editor machine drops the pristine/dirty patch gating and sync-time patch forwarding through the sync actor; setup now flushes deferred patches/mutations at ready. Sync no longer tracks read-only for auto-resolution; updateBlock wholesale-replaces children when keys are missing or unusable. Orphaned markDefs are no longer stripped on intake—they are pruned on the next local edit to the block.

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

@vercel

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

Request Review

@github-actions

github-actions Bot commented Sep 8, 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 / 253.8 KB -525 B, -0.2% 53 ms -2 ms, -3.2%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +1.0%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 5 ms -0 ms, -2.1%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 21.7 KB None 6 ms -0 ms, -2.4%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 4 ms -0 ms, -1.4%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 9.1 KB None 5 ms -0 ms, -0.0%
⚪ @portabletext/markdown / @portabletext/markdown export 374.2 KB / 106.4 KB None 37 ms +3 ms, +7.7%

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.

Comment thread packages/editor/src/editor/sync-machine.ts Outdated
@christianhg
christianhg force-pushed the emit-repair-patches-immediately branch from 225ddef to ff45522 Compare September 8, 2026 12:32
@changeset-bot

changeset-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2c75642

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

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

Comment thread packages/editor/src/editor/sync-machine.ts Outdated

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

Comment thread packages/editor/src/editor/sync-machine.ts
@christianhg
christianhg force-pushed the emit-repair-patches-immediately branch from 11039dd to 95972c0 Compare September 9, 2026 10:47
@christianhg
christianhg force-pushed the emit-repair-patches-immediately branch 2 times, most recently from 95972c0 to 1aaf52d Compare September 9, 2026 10:51
@christianhg
christianhg force-pushed the emit-repair-patches-immediately branch from 1aaf52d to eb27e3d Compare September 9, 2026 13:43
@christianhg
christianhg force-pushed the emit-repair-patches-immediately branch from eb27e3d to 257636d Compare September 9, 2026 14:33

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

Comment thread packages/editor/src/editor/mutation-batcher.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.

Stale Bugbot comment from a previous run.

Comment thread packages/editor/src/editor/remote-patches.ts Outdated
…lization the sole repairer

When a value entered the editor with structurally invalid content
(blocks or children without a `_key`, missing or empty `children`
arrays, duplicate keys), the engine repaired it right away but the
repair patches were parked: the editor machine's `pristine` state
deferred `internal.patch` and `mutation` events produced under
normalization until the first local edit flipped it to `dirty`.
Parked patches go stale under concurrent remote traffic (a sibling
insert shifts the indices a parked repair addresses, so the flush
re-keys the wrong node). The mutation batcher additionally dropped
pending work on a read-only unmount, `validateValue` repaired some
of the same defects before ingestion with its own patch shapes (its
insert branch never reported them), and a block missing its `_key`
failed validation into the invalid-value flow.

The parking machinery is deleted rather than gated: `writing` emits
`internal.patch`/`mutation` directly (the `pristine`/`dirty` split,
the normalization guard, and the conflict-discard actions are gone),
and events deferred during setup flush FIFO when setup completes.
Nothing keys on document pristineness anymore. The mutation batcher
keeps one gate, and it is a different kind: `patch` events relay
immediately regardless of read-only state, but mutation bulks hold
while the editor is read-only and flush on the first tick after it
becomes editable. Studio and Canvas flip the editor read-only when
the connection drops and both throw on mutations delivered while
read-only, so an ungated flush loses the edit typed just before the
flip (pinned by a test whose host listener rejects mutations while
read-only, red on the ungated batcher). The cleanup flush is
unconditional, so pending work is handed over on unmount instead of
dropped. `isDeferringMutations` counts only bulks holding a patch
accumulated while the editor was editable: repair bulks held by a
read-only editor do not latch the sync machine's busy guard, so a
read-only viewer of a malformed document keeps applying incoming
value updates (pinned red on the latching version). Held bulks
without editable-time patches are culled when inbound state applies
(value sync or remote patches settle, signalled via an emit ordered
after the pass's own repair emissions; a plain call misordered under
reentrant streamed syncs and lost the fresh re-mint, pinned red):
repairs are re-derivable, so a superseded repair is dropped rather
than delivered against state that moved past it. A remote-patches
batch culls only when it changed engine state: a full snapshot always
supersedes or re-mints a held repair, but a no-op patch batch
supersedes nothing and ran no normalization to re-mint, so it keeps
the held repair (pinned red on the ungated cull); the batch's
`changed` flag accumulates across patches instead of reflecting only
the last one. One consequence: an operation whose patches span an
inbound application splits into two mutation events.

`validateValue`'s `autoResolve` resolutions are deleted with their
reporting machinery (`applyAutoResolution`, the sync actor's
out-of-band `patch` channel); validation passes mechanically fixable
shapes through and engine normalization repairs them on intake,
emitting engine-shaped patches with `origin: 'local'` (minimal `set`
on the minted `_key`, placeholder-span `insert` before
`children[0]`). `updateBlock` gains a wholesale-set fallback for raw
input: children arrays containing keyless children, or a raw empty
`children` replacing populated children, are set wholesale instead
of reconciled per child, so no `{_key: undefined}` path segment is
ever applied; normalization then mints the keys.

The invalid-value flow remains for defects that need a human (a
non-object block, an unknown `_type`, a span whose `text` is not a
string), and its resolutions address the defective block truthfully:
block anchors resolve through `nodeSegment` (numeric index when the
key is unusable, instead of `{_key: undefined}`, which keyed
matching would resolve to the first keyless sibling), and
`validateValue` takes the block's base index so paths and
descriptions carry the block's document position rather than its
position in the one-block slice `syncBlock` validates (previously
always 0: applying such a resolution rewrote the first block).

Deliberate changes: orphaned `markDefs` are no longer pruned at
intake (the engine's prune is local-only by the two-class model);
they go when a local edit next dirties the block, as a `set` of the
filtered array. Sync `insert` operations carry the raw block, and
normalization repairs follow as their own operations. A snapshot
echoing a still-keyless copy of an already-repaired block re-mints
on the next sync: whole-value sync has no key to correlate an echo
by. Cosmetic normalization (span merging, `markDefs`/`style`/`marks`
defaults) still materializes at the first local edit.
`InvalidValueResolution.autoResolve` is deprecated, never set.

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

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2c75642. Configure here.

// Every `patch` event marks a change the editor produced on its own,
// a user edit or an intake/normalization repair, never a remote
// application bouncing its own patches back; either way, the store
// now lags the editor until the next mutation flush.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Read-only repairs stall value sync

Medium Severity

Intake repair patch events now relay while the editor is read-only, and SDKValuePlugin still treats every patch as an in-flight local write. The machine moves to local write and waits for a mutation that the batcher holds until the editor becomes editable, so whole-value remote updates stay parked in pending sync for the life of a read-only session.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c75642. Configure here.

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