Skip to content

feat(io): model the editor's host protocol and run the Gherkin scenarios against it - #3343

Open
christianhg wants to merge 85 commits into
nextfrom
feat/io-protocol-model
Open

christianhg wants to merge 85 commits into
nextfrom
feat/io-protocol-model

Conversation

@christianhg

@christianhg christianhg commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

What

Two private additions that prove the editor's next host contract before any of it lands in @portabletext/editor.

packages/io (@portabletext/io) is the model. src/protocol/io.ts is the editor's side of the protocol, written against a two-function picture of an editor, EditorForIo = {on, send}, with the events change, ready and closing and the messages load, resync and apply. It imports nothing from the editor package and nothing from the fakes, so the same code is meant to run against the real editor once it exposes those three messages. The host-facing side has the editor's own store shape, getSnapshot() with context.status and context.sync, subscribe, on(type, listener) and send(message), so a UI reads io the way it reads the editor (useSelector(io, (snapshot) => snapshot.context.sync)). src/protocol/host.ts is a reference host in three shapes: plain (saves each mutation under the transaction ID the mutation proposes), folding (one request per flush with its own ID, announced per mutation with mutation sent, the Studio shape) and self-confirming (no listener, one writer, confirms each mutation by forwarding the transaction it just saved). Transaction IDs come from an injected generator, a UUID by default. Everything in src/fakes/ is fake: a document behind EditorForIo with a caret, a server that applies patches with Content Lake's measured rules and records a transaction for every mutation, and a network of queues the steps drain. src/testing.ts exports the fakes and the Gherkin runner, src/index.ts the real halves.

The suite is 71 scenarios in gherkin-spec/, every state in textspec, run twice: once with transactions carrying only patches, once with each transaction carrying the server's copy as well (transaction.value). After every step the world asserts that each editor's tree equals io's working copy, so a divergence between what io believes and what the editor shows fails the step that caused it. One scenario stays red on purpose and is skipped: the quiet document whose host never says feed lost, which ends with sync saying saving forever, the stalled state the protocol doesn't have yet.

apps/io-playground drives the same world in a browser (pnpm dev:io-playground). Editor A, its link, the server, Editor B's link and Editor B sit side by side, every message between a host and io is a card, and every value, mutation, transaction and apply opens to its patches. Free play has the three host presets, "the feed dies" followed by "say feed lost" or "do nothing" (the stall, with a timer), work dropped notices with the dropped text and a copy button, a control that corrupts the stored copy with each floor violation and two doors to deliver it through, the first commit with load disabled after it ends, and the transaction.value toggle. Scenario mode runs any scenario one step at a time and marks the failing check in place.

The contract being modeled

The host forwards every transaction the server records on the document, in order, its own included, each with its ID, the revision before, the revision after and the field's patches (and the server's copy when it can afford it). The editor keeps the server's copy at a known revision as its base, one mutation in flight and the pending edits, and the screen is those three put together. A mutation is confirmed when its own transaction comes back on the feed, never on the save reply: a probe of Content Lake showed the reply overtaking the feed in 16 to 20 percent of close two-writer races. Every mutation proposes a transaction ID, unique in the dataset, and a host that saves it as its own request uses it as is; a host that folds several mutations into one request picks the request's ID and says so with mutation sent. One ID for every attempt at a mutation, since a second request with the same ID is a 409 that changes nothing (measured), so a retry after a lost reply lands once.

Between load and resync, which carry whole values, the editor receives apply: keyed instructions io authors from its working copy before and after a transaction. A patch on a place nothing unlanded touched is forwarded as it is, which gives the editor the exact where for the caret. A patch on a block that unlanded work also touched becomes a set of that block from the new working copy. A list (the block list or a block's children) that both sides changed, or in which the transaction changed a _key, is lined up key by key and the whole transaction's patches on it are replaced by the line-up. The editor's own patches in its own echo apply nothing, and an apply with empty patches still goes out when the base moved under local work, carrying underneath, the transaction's patches as they moved the base, for an editor-owned history to rebase on.

A transaction that doesn't connect is held, and after ten seconds reported as out of order. A remote insert whose key the base, the mutation in flight or a pending insert already uses is duplicate key. A transaction that leaves the base below the floor is invalid content. An own echo carrying a set or unset on a path strictly above one the mutation touched is echo mismatch. After any error or feed lost the editor is out of step: it keeps taking local edits and recognising echoes, but sends no mutation until the resync, so a detected collision can't become a duplicate on the server. The resync carries the outcome of the mutation in flight (the host learns it by re-submitting under the same ID), takes the copy as the new base, re-applies pending, re-keys a pending insert whose key the base now has, and clears history. A mutation marked 'not applied' rejoins pending ahead of the rest, a rejected mutation the resync drops is reported as work dropped 'rejected', and the editor's own patches that its echo shows were applied as no-ops (the target was gone when the transaction ran) are reported as work dropped 'no target' at the echo.

Whole values are repaired to the floor on load and resync: a missing _key or _type is set, a span text that isn't a string becomes "", a text block's children keeps its objects and gets one empty span only when none are left, and a block that isn't an object is left out of what the editor sees and never written to, with io keeping the map from the editor's positions to the stored array's. Repair keys are an FNV-1a hash of the revision and the stored path, so two editors repairing the same defect mint the same key. Emptying a field that still holds a stored non-object unsets the visible blocks by key, not the field.

The lifecycle has no claim: mount() ends the first commit, a load before it is taken, a second replaces the first, a load after it throws, and ready fires at the end of the commit with the content or empty.

Design notes

Undo is a model artefact here, kept out of io in testing (withModelUndo) and driven through a test seam, not through an editor message: the real editor owns history, and how it learns about base changes it never applied to its tree is the open design that underneath exists for. The apply authoring lives in src/protocol/apply.ts. Undo reverts at the position the typing happened, mapped through what moved underneath, and reverts only the editor's own changes.

The fake document's caret follows each instruction: a text patch before the caret in its span shifts it, a block set maps it by the text change of its span, an unset of its block moves it to the neighbour, an inserted span before its span leaves it. The caret scenario that was red for the whole life of this PR passes since apply carries the where. One scenario pins a case the contract does not fix and says so: typing at the end of a block that another editor splits first lands in the block the split left it in, with nothing lost and nothing reported.

The suite observes the editor only from the outside: what it shows, what it has sent, what listeners heard, what the server has, plus the tree-equals-working-copy invariant. The steps check with plain Errors rather than vitest's expect, so the same step definitions run in the playground. The fake server's semantics each trace to a measurement on the service (the no-op transaction, the 409 on a reused ID, the acceptance of every floor violation, set through a primitive replacing it); request failures are injected by status from the outside, the fake decides nothing about batches.

Not modeled

Batching by time, operations in change events (patches stand in), redo, selection beyond a caret in one block, an above-floor object block in the fake document (an image), normalization of marks, the applyAll wrapper that stands in until the missing-parent fix on main reaches next, and remote index-addressed patches on a field with stored non-object blocks in patches-only mode. Nothing in this PR is consumed by a published package.

…rios

`@portabletext/io` is where the v9 host contract gets an implementation
before it lands in the editor: the editor's side of the protocol and the
host adapter written for real, with a fake document, server and network
around them. The package is private and runs unit tests only (a single
vitest `unit` project on node).

The 24 Gherkin scenarios in `gherkin-spec/` are the suite. They are the
executable companion to the protocol spec: two editors, one server, and
what each editor shows and sends, with every state in textspec notation.
`scenarios.test.ts` proves the racejar wiring with one inline feature for
now; the real runner arrives with the step definitions.
…nd out

The fake document is a `PortableTextBlock[]` with a caret. Each user
action (set a style, type, put the caret, insert a block, delete a block)
applies to the tree and produces the patches the real editor would emit:
`set` for a style, `diffMatchPatch` for typed text, `insert` and `unset`
for blocks. Each action also records its inverse patches for undo.

The placeholder follows the protocol's empty-field rules and the shapes
in `subscriber.patch-generation.ts`: an empty document shows one
placeholder block known by key, the first edit into it prepends
`setIfMissing([], [])` and `insert([block], 'before', [0])`, deleting the
last block appends `unset([])`, and a block received from the host is
real content whose edits produce only their own patch.

State goes in and out as textspec through `@portabletext/test`. The
comparison helper compares keys only when the expected notation names
them and the caret only when the expected notation has one, so scenarios
can say exactly as much as they mean.
…rtual clock

The server holds one document with a field and a revision, applies each
batch's patches with `applyAll` under Content Lake semantics, and records
a transaction for every batch it receives, changed or not, as measured
against the service. A keyed patch whose target is gone is a no-op, an
`insert` with a key that already exists is stored as sent, and only
`refuse` is a decision rather than a result. `applyAll` throws when a
path runs into `undefined`, so the server skips a patch whose parent no
longer exists instead of failing the transaction. Deleting the document
records `resultRev: undefined`, recreating it `previousRev: undefined`.

The network holds queues that only the test steps drain: save requests,
one reply per batch, and one feed per editor that delivers transactions
in whatever order a step asks for, so both sides of a race can be
written. The clock is virtual: timers fire only inside `advance`.
…rough host

`createIoEditor` is the protocol as `protocol.md` writes it, in plain
TypeScript with no React, XState or `@portabletext/editor` dependency, so
it can move into the editor later. It keeps a base (the server's copy at
a revision), one batch in flight, a rejected batch, pending changes and
held transactions, and derives the screen as base plus unconfirmed work
under Content Lake semantics (`applyWithContentLakeSemantics`, shared
with the fake server).

A transaction whose `previousRev` doesn't match is held, the chain is
applied once the missing one arrives, and `out of order` fires only when
nothing connects within 10 s on the virtual clock. Confirmation is the
echo: the batch named by `mutation sent` leaves the ledger when its
transaction arrives, whether applied, held or ignored while out of step,
and a held echo keeps its patches on screen until its transaction reaches
the base. A rejection blocks sending until a resync, which is refused
while a batch is in flight and drops the rejected batch and its undo
steps. A pending insert whose key collides with the base is re-keyed,
with later patches, undo steps and the caret following. Undo puts back
what the base holds now and is never rebased over the editor's own
contribution.

`createPassThroughHost` maps each batch to its transaction, reports
`mutation sent` before the save request, forwards the feed unchanged,
reports rejections, and fetches the server copy for `load` and `resync`.
The world wires two editors with their hosts, the fake server and the
network, with a shared key generator for the initial document and one per
editor for new keys. The steps are the vocabulary from the scenarios page,
one definition per keyword they appear under: happenings as `When`, checks
as `Then`. `has sent batch N` asserts the exact count, `has sent nothing
new` compares with the previous `has sent` check, `is in step` means no
`error` since the previous check, and `shows` and `the server has` compare
keys and the caret only when the expected notation names them. The
`{textspec}` parameter is a greedy quoted string so keyed notation with
inner quotes parses, and `{batch}` carries an editor-and-number pair
because racejar passes at most three step arguments.

All 24 scenarios (29 runs) pass. Breaking nine mechanisms one at a time
turned eight of them red; the ninth (dropping the rejected batch from the
screen) is covered by a unit test, since no scenario delivers a remote
transaction while a rejected batch is on screen.
… its rules

Undo no longer replays stored inverse patches. Each action records what it
did, and undo reverts that through the document's own actions, so the
empty-field rule decides whether an `unset([])` is needed and a delete is
only re-inserted while its key is absent. Replaying inverses let undoing
the first keystroke into an empty field wipe another writer's block, and
let undoing a delete restore a stale block. With undo derived at undo
time, the editor no longer needs to find its own patches inside a mixed
transaction, so that mechanism and the stored-inverse rebasing are gone.

The host now applies the feed handoff (drops what the feed delivered up
to the copy's revision when it fetches a copy) and saves the `final` batch
only after the in-flight batch's request was taken. Content Lake
semantics distinguish a missing parent (no-op) from traversal into a
primitive (`patch failed`). Keys: a local insert re-keys at action time
when a sibling holds its key, repairs generate keys that collide with
nothing, and an insert carrying the same key twice is a `duplicate key`.
Liveness warnings, `ready` for an unclaimed editor (on `mount`), the
resync warning for unsent parts that didn't apply, and rejecting inputs
after unmount are in. The comparison helper reads named keys from the
notation's own `_key` attributes.

The steps observe delivered events only: the world subscribes to each
editor at creation and records what listeners receive, and the server
takes the request the host queued instead of the editor's own record.
Four scenarios are new or extended. A transaction the resync copy already
covers is dropped by the host. A received insert that reuses a key the
base already has puts the editor out of step (the step-2 check, which no
scenario reached). An echo that skips ahead is held, confirms its batch
and keeps the typing on screen (the held-echo mechanism, which only unit
tests caught). The undo scenario now has a non-empty undo stack when the
resync clears it, and the rejected-batch outline types after the
rejection to pin that Blocked means no sending.

27 scenarios, 32 runs, 120 tests.
…ange

Undo of a style compares the value the editor set with what the screen
holds now. If they match, undo restores the style the base held right
before the editor's change took effect, captured when the own
transaction applied. If they differ, another writer changed it after, so
undo leaves it alone. This needs no attribution of patches inside a
mixed transaction, at the cost of one case: another writer's change
under the editor's own inside the same transaction is restored to the
pre-transaction value. Undo of a delete re-inserts the block as the base
held it just before the delete took effect there, so a remote change to
the block that arrived while the delete was in flight survives. Undo of
typing finds the typed text nearest its recorded offset, so a remote
insertion before it no longer disables the undo.

The host's feed handoff learns the revision chain from every transaction
it has seen, so a covered transaction that arrives after a resync is
dropped instead of being held for 10 seconds. A late covered transaction
with no known link can't be told from one that skipped ahead and is
forwarded, which the editor holds as the protocol says. `load` after
unmount warns instead of throwing.
@vercel
vercel Bot temporarily deployed to Preview – portable-text-editor-documentation September 29, 2026 09:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – portable-text-playground September 29, 2026 09:29 Inactive
@changeset-bot

changeset-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 6a91f69

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
portable-text-example-basic Ready Ready Preview Oct 2, 2026 6:40am UTC
2 Skipped Deployments
Project Deployment Actions Updated
portable-text-editor-documentation Skipped Skipped Oct 2, 2026 6:40am UTC
portable-text-playground Skipped Skipped Oct 2, 2026 6:40am UTC

Request Review

@github-actions

github-actions Bot commented Sep 29, 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.07 MB / 135.3 KB None 66 ms -1 ms, -1.5%
⚪ @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 7 ms -0 ms, -1.5%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 9.3 KB None 8 ms +0 ms, +2.9%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 4.0 KB None 6 ms +0 ms, +1.5%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 4.1 KB None 6 ms -0 ms, -0.4%
⚪ @portabletext/markdown / @portabletext/markdown export 388.2 KB / 61.8 KB None 28 ms -0 ms, -0.3%

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/io/src/editor.ts Outdated
Comment thread packages/io/src/editor.ts Outdated
The fakes move to `src/fakes/` and the world, steps and parameter types
to `src/scenario/`, all exported, so something other than vitest can
drive the model. The steps check with plain `Error`s instead of vitest's
`expect`, which a browser page can't import. The world gains
`snapshot()`, plain data for every part (each editor's screen, base,
ledger, sent batches and heard events, the server's value, revision and
log, the network's queues and clock), and one method per vocabulary
action so free play and the steps share the same code.
`compileScenarios(featureText)` turns a feature into scenarios whose
steps run one at a time against a world, with the keyword and text of
each step, built on racejar's `compileFeature` and `@cucumber/gherkin`.
…owser

A Vite app that drives `@portabletext/io` the way the test steps do, with
the state on screen: Editor A and Editor B (screen, base, ledger, sent
batches, heard events), the server (value, revision, transaction log)
and the network (save requests, replies, one feed queue per editor with a
deliver button on each item, so a race is played by choosing the order).

Scenario mode lists the 32 runs compiled from the feature files and runs
them one step at a time, with the current step highlighted and a failing
check shown in place. Free play offers every vocabulary action as a
button, runs it through the real step definitions, and appends the
matching Gherkin line to a log that copies out as a new scenario.

@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 apps/io-playground/src/free-play-tab.tsx
Comment thread apps/io-playground/src/editor-panel.tsx Outdated
Comment thread apps/io-playground/src/network-panel.tsx Outdated
…hem all at once

When a scenario ends, the other editor's feed queue often still holds
transactions, and the editor looks frozen. Each editor's header now shows
an amber "N waiting" badge while its feed queue is non-empty, and each
feed section has a "deliver all" button that delivers the queue in order
through the same path as a single deliver, so free play logs one Gherkin
line per transaction. In scenario mode the buttons appear once the
scenario has finished, and delivering then leaves the recorded step
results untouched.
@vercel
vercel Bot temporarily deployed to Preview – portable-text-editor-documentation September 29, 2026 10:17 Inactive
@vercel
vercel Bot temporarily deployed to Preview – portable-text-playground September 29, 2026 10:17 Inactive
The snapshot now carries the Portable Text blocks behind every textspec
(each editor's screen and base, the server's value) and the patches of
every batch and transaction (in flight, pending, rejected, echoed, held,
sent, the server's log, save requests and feed items), so a viewer can
open any of them without reaching into the model. `inspect()` on the
editor exposes the pending changes' patches for the same reason. All
additive, pinned by the full-value snapshot tests.
…h, and narrate each step

Five columns: Editor A, its link, the server, Editor B's link, Editor B.
Each link has two lanes drawn as cards: save requests on their way to
the server, and save replies and feed transactions on their way back,
each with its own deliver button and the editor's own marked "yours".
Every textspec toggles to its Portable Text blocks, and every batch and
transaction opens a drawer with its patches in `@portabletext/patches`
shape, the revisions and the batches it carries.

Every label carries a one-sentence explanation behind an "i" mark, with
a Concepts drawer listing all of them and the notation rules. After each
step the app diffs the world before and after and narrates what happened
("Editor A kept the change (typed "y") as pending, because batch 1 is
still in flight"), and the panels that changed flash. "Replies" became
"save replies", revisions explain themselves on hover, and the base is
labelled as the server's copy at its revision.
@vercel
vercel Bot temporarily deployed to Preview – portable-text-editor-documentation September 29, 2026 10:44 Inactive
…at `load` may do and when

Free play started with `the document is`, which loads both editors and
ends their first commit in one step, so the lifecycle the protocol
specifies was never on screen.

Free play now starts with the editors in their first commit, and each
editor panel marks the first commit while it lasts. `load` stays
available until the first commit ends, a second `load` before ready
replaces the first and the narration says so, and once the editor is
ready `load` is disabled with the reason written next to it: after
ready a load throws, and `resync` takes over. The concepts drawer
describes the first commit.
…nsaction

The world can deliver every transaction with the server's copy after
it, and the playground had no way to turn that on, so `transaction.value`
was never on screen.

The server panel now has a "listener sends the document with each
transaction" toggle. In free play it restarts the world with
`Given transactions that carry the server's copy`, and in scenario mode
it shows what the scenario's Given set. With it on, every
`transaction` on the message path is marked with `value`, and its
details show the copy next to the patches. The concepts drawer says
what changes: io takes the base from the server's copy, and the
patches still travel for the checks, `apply` and the echo.
…rkin-spec`

`features.ts` imported each feature file by name, so a new file in
`packages/io/gherkin-spec` stayed out of scenario mode until someone
added an import.

It now globs the directory: the known files keep their reading order,
and any other follows them alphabetically.
After an `error` or `feed lost`, `transaction` still noted the editor's
own echo and then called `flush`, which emitted the pending changes as
the next batch. A pending block insert keyed `K` that another writer's
insert of `K` had just put out of step (`duplicate key`) went out with
the next batch as soon as the batch in flight came back, and the server
held two `K` blocks before the resync could re-key the insert.

`flush` now emits nothing while io is out of step. io still books local
changes as pending and still takes its own echoes off the batch in
flight, and the resync, which re-applies and re-keys the pending
changes, sends them. Closing while out of step sends no final batch and
emits `work dropped` with the new reason `closed out of step`, carrying
the pending patches.

The playground's dropped-work notice and concepts explain the new
reason.
`authorInstructions` sorted a transaction's patches one at a time. With
typing pending in block `foo`, a transaction that removed `foo` and
inserted a block keyed `foo` after `bar` had its removal turned into a
`set` of `foo` (a block unlanded work touched) while the insert, on a
block list unlanded work had not inserted into, was forwarded: the tree
became `[foo, bar, foo]` against the working copy `[bar, foo]`. A
`_key` `set` on a touched block followed by an insert after the new key
lost both blocks: the forwarded insert found no reference, and the key
change became an `unset` of the old key.

io now decides per list (the block list or a block's `children`). A
list is lined up against the working copy, key by key, when the
transaction inserts into it, removes from it or changes a key in it and
unlanded work touched it (any change to its items or under them), and
whenever the transaction changes a key in it. None of the transaction's
patches on a lined-up list is forwarded on its own. Lists only one side
touched keep the forwarding and whole-block `set` rules.

With `transaction.value`, the trailing line-up of the whole field hid
the bug: it lined up whatever the instructions left wrong. The
instructions are now authored against the working copy the patches
alone make, the same in both modes, and only what `value` changed beyond
the patches is lined up after them, so a wrong instruction shows as a
tree mismatch in both modes.

A block-list insert or removal while any work is unlanded now arrives
as a line-up, which emits the same keyed `insert` or `unset` for a
single block.
The editor leaves out the stored blocks that aren't objects, so its
block positions differ from the stored array's indexes once one is
stored. `authorInstructions` forwarded another writer's patch addressed
by index as it was: a `set('h1', [2, 'style'])` on the stored array
`[left, "oops", right]` targets `right`, the editor's block 1, and the
editor applied it to its block 2, which doesn't exist. The tree kept
`right` unstyled against a working copy with `H1: right`.

With no unlanded work, io now walks the transaction's patches over the
base they apply to and readdresses each index-addressed one through the
stored-index map: by the target block's key, or by its editor position
when it has no key. A patch by index that targets a block that isn't an
object, or brings one, lines up the block list instead. With unlanded
work, a patch by index lines up the block list, as before.
…inserted span

`followCaret` moved the caret only for a text patch on its span, so a
`set` of the caret's block, the shape a remote patch on a block with
unlanded work arrives in, kept the caret's offset in its span whatever
the new text: with `foo barx|` on screen and an echo folded with another
writer's `baz ` before it, the caret landed at `baz foo |barx`. An
insert returned the caret untouched as a block offset, so a span
inserted before the caret's span pulled the caret into the new span
(`bar| foo` instead of `bar foo|`).

Any patch other than a text patch on the caret's span now maps the
caret's offset by the change between the common prefix and suffix of
the span's text before and after, so text inserted before the caret
shifts it. An insert goes through the same path as any other patch:
the caret stays in its span at its offset there, and its block offset
is read from the block after the patch.
`isBelowFloor` passed a text block whose child had no `_key` or
`_type`, so a transaction leaving one applied, though `repairToFloor`
already repairs both on `load` and `resync`. Collision detection only
knew block keys: `insertedBlockKeys` collected inserts into the block
list, so a pending or unconfirmed span keyed `K` and another writer's
span `K` in the same block raised nothing and the working copy held
two `K` children, while another writer's span keyed like an unconfirmed
block was reported as `duplicate key` though the two are not siblings.
The resync only re-keyed pending block inserts.

`isBelowFloor` now fails a text block with a child that lacks `_key` or
`_type`. Inserted keys are collected per sibling list (the block list or
one block's `children`), and the unconfirmed-insert check, the
pending-key check and the resync's re-keying each compare a list's keys
with the same list in the base. The resync renames keys in child lists
before block keys, so a child list's path still names its block's old
key when it is renamed.
The pending-key check stepped through the transaction's patches over
the old base. A transaction with `value` and empty `patches`, whose
value held a block keyed `K` while a pending insert also inserted `K`,
passed the check: io took the value as its base, and the working copy
held two `K` blocks.

`pendingKeyCollision` now decides against the new base, whichever way
it was built, and still names the first patch after which the key
appears when the patches say so. A collision only `value` shows emits
`duplicate key` without a patch.
`repairToFloor` replaced any `children` that wasn't a non-empty array
of objects with one empty span, so `[span("good"), 42]` lost `good`
along with `42`.

A text block's child that isn't an object is now removed by index,
last first so the earlier indexes hold, and the remaining children are
repaired as before, addressed by their index after the removals and
keyed by the hash of their stored index. `children` becomes one empty
span only when it isn't an array or holds no object.
…field

The editor empties its field with `unset([])`, and io booked it as it
came. While the stored array held a block that isn't an object (left
out of what the editor gets), deleting the last visible block sent the
whole-field `unset` and removed the stored block too, the one write to
a non-object block the floor rules say never happens.

`takeLocalChange` now books each whole-field `unset` as keyed `unset`s
of the blocks that are objects in the working copy at that point, while
the working copy holds any block that isn't an object. With none stored,
the batch keeps `unset([])`. A new step, `{editor}'s batch {int} does
not empty the field`, pins the keyed shape.
`findTreeMismatch` compared an empty tree whenever the document
reported a placeholder key, whatever the document showed. A placeholder
that had picked up text (or a second block) passed the tree check
against an empty working copy.

The tree counts as empty only when the document shows exactly one text
block with the placeholder's key and one empty span. Anything else is
compared as content and recorded as a mismatch.
…ure checks for malformed content

The "reload Editor A from the server" door runs `the server's copy
changes without a transaction so ...`, which called the fake server's
`alterLatestCopy`. It threw on a fresh document: it required a recorded
transaction, though corrupting the stored copy is a fact about stored
state, not about any transaction. The fake's `alterStoredCopy` now
alters the stored field whether or not a transaction was recorded, and
updates the copy after the latest transaction only when there is one.

"capture checks" fed the server's display text back as a step: with a
block that isn't an object stored, `the server has ""oops";;B: bar"`
did not parse. `serverChecks` writes the server's checks in the step
vocabulary instead: its blocks that are objects as textspec, and `the
server has a block that is not an object` for the rest. When a block
below the floor can't be spelled as textspec, the blocks go unchecked
and the narration says why. `formatTextspec` is exported from
`@portabletext/io/testing` for it.
…yloads

Four scenario-shaped tests in `io.test.ts` and `document.test.ts` get
their `Scenario:` prefix. The network test builds its value with
`createTestKeyGenerator` instead of hardcoded keys. The two
`world.test.ts` message-path tests assert the full messages instead of
a mapped summary of route and type. The scenario in
`other-editors.feature` that turns on the server's copy itself says
that it runs with values in the "patches only" mode too.

The README's "Not modeled" names the above-floor object block (an image)
in the fake document, whose textspec access throws, and the repair-key
note, here and on `mintRepairKey`, says why two editors agree: they
repaired the same revision, received the same value, and so resolve the
taken-key suffix the same way. Two comments on `deriveScreen` and
`queueFloorRepair` that restated the code are gone.
@vercel
vercel Bot temporarily deployed to Preview – portable-text-playground October 1, 2026 21:25 Inactive
@vercel
vercel Bot temporarily deployed to Preview – portable-text-editor-documentation October 1, 2026 21:25 Inactive

@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 3af96cb. Configure here.

Comment thread apps/io-playground/src/drawers.tsx Outdated
`Io` was a bag of per-message methods (`load`, `transaction`,
`mutationSent`, `mutationRejected`, `feedLost`, `resync`), getters
(`getStatus`, `getSync`, `getBase`, `getWorkingCopy`, `inspect`), one
untyped `on(listener)` and `undo`. A host had to learn a second shape
next to the editor's, and nothing in it worked with
`useSyncExternalStore` or `useSelector`.

`Io` is now `getSnapshot`, `subscribe`, `on` and `send`, as the editor
has them. `getSnapshot` returns `{context: {status, sync, rev,
inFlight, pending}}` and keeps the same object until a field changes.
`subscribe` takes an observer or a function and calls `next` once at
the end of every entry point that changed the snapshot: a message, an
editor event, the held-transaction timeout. `on(type, listener)` filters
`mutation`, `error`, `work dropped` and `warning`, or takes `'*'`.
`send` takes `load`, `transaction`, `mutation sent`, `mutation
rejected`, `feed lost`, `resync` and `close`, where `close` runs what
the editor's `closing` runs and releases io's editor listeners.

The base, the working copy, the ledger and `undo` move behind
`getIoInternals(io)`, which only the model's world and tests import;
`extendIo` keeps them reachable from a wrapped io. The host, the world,
the steps and the tests speak `send`/`on`/`getSnapshot`. The README
gains an API section.

Behavior unchanged: every scenario runs as before.
io emits `mutation` events and the host answers with `mutation sent`
and `mutation rejected`, keyed by the mutation's `id`, yet the payload
type was `MutationBatch` and the code, the docs, the Gherkin vocabulary
and the playground called it a batch. Two words for one thing, and
"batch" suggested a grouping io does not do.

`MutationBatch` is `Mutation`, and every name and sentence follows:
`IoSentMutation`, `MutationSnapshot`, `MutationReference`,
`SavedMutation`, `mutationIds` on transactions, `mutations` on frozen
requests, `foldMutations` on the host, "mutation in flight" in the
docs. The Gherkin vocabulary reads `has sent mutation 1`, `the server
receives Editor A's mutation 1`, `hosts that fold mutations into shared
requests` and so on in every feature file, and the playground's labels,
concepts and narration say mutation. The host's documentation now says
that a transaction usually carries one mutation and a folding host's
can carry several. The README's "batching by time" becomes "sending on
a timer".

Behavior unchanged.
io proposed `${id}-t${counter}` as each mutation's transaction ID. That
is unique only among one editor's mutations under one `id`: two
sessions created with the same `id`, or an editor remounted with a
fresh counter, propose the same transaction ID, and the server refuses
the second save as a duplicate of the first (a 409), which the host
reads as "already landed".

`createIo` takes `transactionIdGenerator`, called once per mutation it
emits. The default is a random UUID: `crypto.randomUUID()` where the
runtime has it, a version 4 UUID from `Math.random` otherwise. The
world injects `createTestKeyGenerator(`${id}-t`)` per editor, so the
scenarios and tests name transactions `A-tk0`, `A-tk1` instead of
`A-t1`, `A-t2`. A test pins the default with and without
`crypto.randomUUID`. The README gains a note on transaction IDs.
`createIo` took `applyLocalEdit`, held the undo ledger and exposed
`undo` and `undoDepth`. Undo is a stand-in for the editor's history, not
part of the protocol, yet a host creating io had to supply a callback
for it, and io's transaction and local-change paths interleaved ledger
bookkeeping with the protocol's own.

The ledger now lives in `src/scenario/model-undo.ts`, exported from
`testing` as `withModelUndo(io, applyLocalEdit)`, which returns io with
`undo` and `getUndoDepth` on top. It hears io through a tap on io's
internals: `localChange` before a change is booked (with the working
copy before it and its patch offset), `mutation` before one is
emitted, `transaction` before one moves the base (with the mutations
it confirms and the editor's own patches), and `resync`. It reads the
base, the unconfirmed mutations and the pending patches through
`getLayers`, and warns through io's `warn`. io calls the taps and reads
nothing back. `createIo` no longer takes `applyLocalEdit`, and
`IoLedger` loses `undoDepth`.

The node helpers both sides use (`isEqual`, `itemKey`, `keyOf`,
`childrenOf`, `findBlock`) move to `src/protocol/nodes.ts`. The undo
tests move from `io.test.ts` to `model-undo.test.ts`, unchanged. The
world wraps every editor's io with `withModelUndo`, so the scenarios
and the playground undo as before. The README says undo is a stand-in
in `testing`, not part of io.

Behavior unchanged.
`authorInstructions`, `lineUpList` and the analysis behind them
(`placeOf`, `listChangeOf`, `touchedPlaces`, `addressForEditor`,
`longestCommonRun` and the rest) made up about 400 of `io.ts`'s 1,860
lines, and none of it reads io's state: it takes the stored, shown and
wanted values, the transaction's patches and the unlanded ones, and
returns instructions. It moves to `src/protocol/apply.ts`, which
`io.ts` imports, and the tests that pin its instructions move from
`io.test.ts` to `apply.test.ts`.

Behavior unchanged.
io reported `work dropped` with reason `no target` only for pending
patches. A mutation in flight whose target another writer's
transaction took away first went unreported: Editor A types into a
block and sends it, Editor B's removal of that block lands first, the
server applies A's `diffMatchPatch` to nothing, and A's echo confirms
the mutation as if it had saved. The words were gone with no event to
tell the host.

`applyTransaction` now matches the incoming patches against the
confirmed mutations' own patches and checks each one with `hasTarget`
against the base right before it applies. Those without a target are
reported once, after the transaction applies, as `work dropped` with
reason `no target`, with a warning. Patches already reported while
pending are left out, so a pending patch that went out as a no-op is
not reported again at its echo.

Pinned by an `other-editors.feature` scenario (A's echo comes back as
a no-op, A is told its work was dropped, the server has no trace of
the typing) and a unit test asserting the whole dropped-work list,
both red without the report. The `WorkDropped` doc, the README and the
playground's explanations say that `no target` covers sent work too.
… first

No scenario covered a split racing with typing, and the fake document
had no split. `splitAtCaret` now emits what the editor's `insert.break`
emits: a `diffMatchPatch` that cuts the caret's span, an `unset` of each
child after it, and an `insert` after the block of a new block whose
first span keeps the span's key and holds the rest. The vocabulary
gains `the block is split at the caret` (and `... in Editor B`).

The scenario in `concurrent-edits.feature`: Editor A types "x" at the
end of "foobar" and sends it, Editor B splits the block after "foo",
and B's split lands first. A's `diffMatchPatch` still names the span
key, which now sits in the first block with "foo", and
diff-match-patch's fuzzy match puts the insertion at the end of "foo",
so the server makes it "B: foox;;B: bar". The word is not lost and no `work dropped` fires,
but it lands at the end of the first block instead of after "bar".
Every screen converges on the server's copy and A stays in step. The
scenario pins that outcome.
`applicable.ts` decided from the world's copies of io's state: the fake
document's `status`, the ledger's `outOfStep`, `inFlight` and
`pending`, and a `sync` the world snapshot kept next to them. Each was
a second reading of what `io.getSnapshot()` already says, and nothing
kept the two in agreement.

The world's `EditorSnapshot` now carries `io`, the `context` of
`io.getSnapshot()` verbatim, and drops its own `sync` and `outOfStep`.
`applicable.ts` gates on `io.status`, `io.sync` (`'out of step'`,
`'blocked'`), `io.inFlight` and `io.pending`, and takes from the world
only what io does not know: read-only, the host shape, held
transactions, the undo depth, and the world's numbering of mutations,
which it finds by `io.inFlight.id` in `sentMutations` (now carrying
each mutation's `id`). The explanations are unchanged. The editor
panel, the server panel, free play and the narration read `io.sync`
too.

The details drawer explained `closed out of step` with the text for
`closed while blocked`, and the narration did the same: each now says
the editor closed while out of step.
@vercel
vercel Bot temporarily deployed to Preview – portable-text-editor-documentation October 2, 2026 06:39 Inactive
@vercel
vercel Bot temporarily deployed to Preview – portable-text-playground October 2, 2026 06:39 Inactive

This branch was successfully deployed

1 active and 2 inactive deployments
Preview – portable-text-example-basic — 6a91f69a Deployed Oct 2, 2026 by vercel[bot]
Preview – portable-text-playground — 6a91f69a Deployed Oct 2, 2026 by vercel[bot]
Preview – portable-text-editor-documentation — 6a91f69a Deployed Oct 2, 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