feat(io): model the editor's host protocol and run the Gherkin scenarios against it - #3343
christianhg wants to merge 85 commits into
Conversation
…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.
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
Bundle Stats✅ No significant changes. All scenario measurements (7)🗺️
Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time. |
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.
…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.
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.
…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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
`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.

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.tsis the editor's side of the protocol, written against a two-function picture of an editor,EditorForIo = {on, send}, with the eventschange,readyandclosingand the messagesload,resyncandapply. 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()withcontext.statusandcontext.sync,subscribe,on(type, listener)andsend(message), so a UI reads io the way it reads the editor (useSelector(io, (snapshot) => snapshot.context.sync)).src/protocol/host.tsis a reference host in three shapes: plain (saves each mutation under the transaction ID themutationproposes), folding (one request per flush with its own ID, announced per mutation withmutation 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 insrc/fakes/is fake: a document behindEditorForIowith 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.tsexports the fakes and the Gherkin runner,src/index.tsthe 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 saysfeed lost, which ends withsyncsayingsavingforever, the stalled state the protocol doesn't have yet.apps/io-playgrounddrives 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 andapplyopens 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 droppednotices 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 withloaddisabled after it ends, and thetransaction.valuetoggle. 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
mutationproposes 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 withmutation 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
loadandresync, which carry whole values, the editor receivesapply: 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 asetof that block from the new working copy. A list (the block list or a block'schildren) 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 anapplywith emptypatchesstill goes out when the base moved under local work, carryingunderneath, 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 isduplicate key. A transaction that leaves the base below the floor isinvalid content. An own echo carrying asetorunseton a path strictly above one the mutation touched isecho mismatch. After anyerrororfeed lostthe editor is out of step: it keeps taking local edits and recognising echoes, but sends no mutation until theresync, 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 aswork 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 aswork dropped 'no target'at the echo.Whole values are repaired to the floor on
loadandresync: a missing_keyor_typeis set, a spantextthat isn't a string becomes"", a text block'schildrenkeeps 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, aloadbefore it is taken, a second replaces the first, aloadafter it throws, andreadyfires 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 thatunderneathexists for. Theapplyauthoring lives insrc/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
setmaps it by the text change of its span, anunsetof 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 sinceapplycarries 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'sexpect, 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,setthrough 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
changeevents (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, theapplyAllwrapper that stands in until the missing-parent fix onmainreachesnext, 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.