Skip to content

fix: let the newest incoming value supersede a parked pending value - #3281

Open
christianhg wants to merge 1 commit into
mainfrom
sync-newest-value-supersedes-pending
Open

christianhg wants to merge 1 commit into
mainfrom
sync-newest-value-supersedes-pending

Conversation

@christianhg

@christianhg christianhg commented Sep 16, 2026

Copy link
Copy Markdown
Member

A newer document value arriving while an older one is still waiting to sync no longer loses to the older one. Previously, once a value was parked awaiting sync, a further incoming value was compared against the last value the editor had synced, not the parked one. A value that happened to match that older baseline (for example, the host unsetting a field back to its prior state) was dropped as "nothing new", so the stale parked value synced in instead of it. The editor could then treat that stale sync as persisted, which suppressed the usual rebuild on the next edit, leaving the edit targeting content the document no longer had.

The is new value guard in the sync machine judged every incoming update value against context.previousValue, even while a value sat parked in context.pendingValue during a busy window (mutations deferred by local edits). The failure needs an editor that started empty: previousValue is still undefined, a stale non-empty snapshot parks, and the host's newer undefined (the field was unset) compares equal to previousValue and is discarded. The stale snapshot then syncs in and is recorded as lastSyncedValue, and patch generation's stale-echo suppressor reads that record as "this block is persisted", so the first edit after emits a keyed patch with no setIfMissing/insert rebuild. Against a store where the field is gone, that patch and every one after it is a silent no-op.

The guard now takes the parked value as its baseline whenever one exists. pendingValue's type cannot distinguish "parked: undefined" from "nothing parked", so a hasPendingValue flag carries that distinction, kept in sync by the same actions that set and clear pendingValue.

Pinned in placeholder-block.test.tsx: a local edit defers mutations, a stale value parks, the newer undefined arrives, a remote root unset lands, and after the deferred mutation flushes the editor must settle on a fresh placeholder rather than the stale block, with the next edit emitting the full rebuild (setIfMissing, insert, then the text patch). The test fails on the pre-fix guard: the stale parked block syncs in instead.


Note

Medium Risk
Changes core value-sync deduplication during busy states; wrong baseline logic could drop or double-apply host updates, though scope is narrow and covered by a new integration test.

Overview
Fixes a race in the sync machine where a newer host value could be ignored while an older snapshot was still parked during a busy (deferred-mutation) window.

The is new value guard always compared incoming update value events to previousValue, so a later update that matched the last synced baseline (e.g. the host unsetting the field to undefined when the editor started empty) was treated as a no-op and never replaced the stale pendingValue. That stale sync could be recorded as persisted and break the next edit’s patch rebuild.

comparisonValue now uses the parked pendingValue when hasPendingValue is set; a new hasPendingValue flag distinguishes “parked undefined” from “nothing parked,” since pendingValue alone cannot. Assign/clear pending actions keep the flag in sync.

Adds a placeholder-block scenario test: local type while busy, stale value parks, newer undefined supersedes, remote unset, then the next keystroke must emit setIfMissing, insert, and the text patch.

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

The sync machine's `is new value` guard judged an incoming `update
value` event only against `context.previousValue`, the last value the
machine had synced. While a value sits parked in `context.pendingValue`
during a busy retry, a further `update value` that happened to equal
`previousValue` was discarded by this guard even though it was newer
than the parked one, so the older parked value survived and synced in
later. A host clearing a field back to `undefined` right after sending
a stale non-empty value hit this exactly: `previousValue` was still
`undefined` (the machine never advanced it for an editor that started
empty), so the clearing update was silently dropped and the stale value
synced in and got recorded as `lastSyncedValue`. Downstream, the
patch-generation subscriber's stale-echo suppressor compares
`lastSyncedValue` against the pre-edit value to decide whether to
rebuild an unset field before the next edit's patches; a wrongly
recorded `lastSyncedValue` suppressed that rebuild, so the next edit's
patches targeted a field the document no longer had.

The guard now compares against `context.pendingValue` whenever one is
parked, and against `previousValue` only when nothing is parked.
`pendingValue`'s type (`Array<PortableTextBlock> | undefined`) can't
distinguish "parked: undefined" from "nothing parked", so a new
`hasPendingValue` flag carries that distinction; every action that
sets or clears `pendingValue` keeps it in sync.

Pinned in `placeholder-block.test.tsx` (home of the other
parked/repaired-placeholder contracts): a local edit defers mutations,
a stale value parks, a newer host-truth `undefined` arrives, a remote
root unset lands, and once the deferred mutation flushes and the busy
retry runs, the parked value must have been superseded. Confirmed red
on the pre-fix guard (the stale parked block synced in as `k2`/`k3`
instead of the freshly normalized `k6`/`k7`) before restoring the fix.
@changeset-bot

changeset-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5e3a278

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 16, 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 16, 2026 11:41am UTC
portable-text-example-basic Ready Ready Preview Sep 16, 2026 11:41am UTC
portable-text-playground Ready Ready Preview Sep 16, 2026 11:41am UTC

Request Review

@github-actions

github-actions Bot commented Sep 16, 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.4 KB +45 B, +0.0% 54 ms +1 ms, +2.3%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms -0 ms, -2.7%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 6 ms -0 ms, -0.7%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 21.7 KB None 6 ms -0 ms, -0.8%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 4 ms +0 ms, +1.7%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 9.1 KB None 5 ms -0 ms, -2.2%
⚪ @portabletext/markdown / @portabletext/markdown export 381.3 KB / 108.2 KB None 33 ms +1 ms, +2.6%

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

This branch was successfully deployed

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