Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Migration

Import documents stored by the v13 (yjs) engine, and upgrade code written for edytor 0.0.11.

This page covers two upgrades: moving stored v13 (yjs) documents to the current engine, and updating application code written for edytor 0.0.11. Edytor is pre-1.0: no release keeps compatibility shims.

Importing v13 documents

The v13 engine and the current one cannot share a live document, a database or a room. Migration is a one-way, non-destructive import of each document’s content into a new store.

Survives Does not survive
The block tree, nesting and order CRDT identity: the imported document is new
Text, marks, inline blocks and block data Edit history and undo stacks
Block and inline block ids (they are data) Updates still pending on an offline v13 device
Presence

The migrator reads a browser’s v13 IndexedDB database named <name> and writes the import into the store createIndexeddbSync(name) opens. It never writes the v13 database, so the old build keeps working on its data and rollback stays possible.

Boot recipe

Migrate first, then open the document. Running it at every start is safe: when there is nothing to import, it only marks the store as migrated.

import * as Y from 'edytor/crdt';
import { bindCrdt } from 'edytor';

const { migration } = bindCrdt(Y);

export async function prepareDocument(name: string): Promise<void> {
  const record = await migration.status(name);
  if (record.status === 'none' || record.status === 'failed') {
    const result = await migration.migrate(name);
    if (result.status === 'failed') console.error('migration failed', result.error);
  } else if (record.status === 'pending') {
    await migration.waitForSettled(name); // another tab is migrating
  }
  // 'rolledback' is an operator decision: never migrate over it automatically.
}
<script lang="ts">
  import { Edytor } from 'edytor';
  import { prepareDocument } from '$lib/migrate';

  let { name }: { name: string } = $props();
</script>

{#await prepareDocument(name) then}
  <Edytor room={name} />
{/await}
  • Tabs migrating the same document take turns through a browser lock; only one imports. migrate(name, { wait: false }) returns { status: 'busy' } instead of waiting.
  • migrate verifies the import against the v13 content, ids included, before committing it, and commits it atomically. An interrupted import leaves nothing behind and runs again next time.
  • result.update holds the migrated state; loadDocument(result.update) opens it headlessly, and result.json holds the content for comparison with a v13 export.
  • To import into another store, such as the local copy of a WebSocket document, pass the v13 name as the source: migrate(sync.persistName, { sourceName: 'doc-42' }). For a shared document, import once, on one client, and let the room distribute it.
Status Meaning
none Never migrated.
pending A tab is importing right now (reported by status, never stored).
active Imported, or nothing to import.
failed Refused: the source is not a v13 edytor document, or it holds updates whose dependencies never arrived (a device that went offline). Retry once that device has synced; do not force.
rolledback Rolled back by an operator. migrate refuses unless { force: true }.

Rollback and re-import

migration.rollback(name) marks the store rolled back and empties it; the v13 database is untouched, so pointing the old build at it again is the rollback. clearDocument(name) deletes the new store entirely.

migrate(name, { force: true }) re-reads the v13 database and restores it in place: every v13 block gets back its type, data, position and content, and blocks created since are deleted. It replaces rather than merges, so use it to recover edits from a v13 device that came back online after the cutover, during a quiet window.

Rooms and servers

  • v13 and current clients never exchange edits: each drops the other’s frames. Ship the new build to every client before announcing the cutover, and use different room names if both versions share a relay.
  • A v13 device that stays offline through the cutover keeps its edits in its own v13 database. They do not appear in the new document unless you re-import them with force.
  • The migrator reads browser IndexedDB only. For v13 documents stored elsewhere (on a server), export their JSON with your v13 build and create the new documents with createDocument({ value }). loadDocument refuses v13 bytes with UnsupportedDocError (reason legacy), and isLegacyDoc(doc) detects a v13 document.

Upgrading from 0.0.11

The current release rewrites the editor’s internals around one owner per fact. The changes most apps hit first are summarized here; the complete list below names every public change.

Stored documents

  • Documents, IndexedDB stores and peers from earlier development builds of the v14 engine are refused (GenerationMismatchError, 'schema-mismatch'): re-import them from JSON. v13 documents import as described above.
  • Seeding a value is deterministic, so replicas seeding the same template converge. BOOTSTRAP_BLOCK_ID is gone, and seeded blocks have no createdBy.

Document API

  • Every facade operation returns an OpResult (applied, noop or refused) instead of a boolean. Operations on deleted or missing blocks are refused.
  • Operations have a two-phase form: facade.prepare.<op>(), facade.apply(plan), facade.compose(...plans). New operations: deleteRange, replaceRange, deleteBlocks, insertFlow, insertBlocks, liftOut. toBlockSpec(json) turns canonical JSON into the BlockSpec that inserts take.
  • New queries: order, compare, next, previous, canPlace, canMerge, defaultChild, fits, nestParent.
  • facade.onChange reports one DocChange per commit. The per-block subscription API (subscribeBlock, subscribe, blockVersion, snapshot), decorateRuns and the bind* building blocks are no longer exported: use onChange with runs(id), a block kind’s transformText for decorations, and bindCrdt(Y) for engine injection.
  • deleteBlock(id) and deleteBlocks(ids) delete only the named blocks; children take their place. Pass { keepChildren: false } for the old whole-subtree delete.
  • Undoing a block’s creation keeps the block while it holds another person’s content; hasBlock(id) stays true, and re-creating an undone id is refused.
  • A document edited without a view knows no block roles unless you pass semantics (defaultSemantics for the bundled kinds).
  • Selection anchors are { b, a }; toJSON() gives inline blocks data: {} when they have none.
  • duplicateBlock(id, freshId) and block(id).duplicate(freshId) call freshId(oldId, kind) for every copied block (kind 'block') and inline atom ('inline'). A callback that returns nothing for an atom gets a fresh atom id; returning nothing for a block refuses the copy.

Editor and plugins

  • value is the initial content and is no longer bindable: bind:value fails type-checking. Follow edits with onChange or edytor.value.
  • Block, Text and InlineBlock are non-reactive handles over the document, and their constructors are gone. Snippets receive a BlockView ({ id, type, data, selected, focused, handle, void }), not a Block: commands go through block.handle.
  • Selection is a value set with selection.select(), setAtRange or setAtTextOffset. The marks at the caret are selection.projection.marks (was selection.state.currentMarks); marks for the next insertion are selection.stage(marks) / selection.pending (text.markOnNextInsert is gone).
  • hotkeys.ts and the HotKeys class are gone. Declare bindings in the hotKeys prop or a plugin’s hotkeys (see Hotkeys); HotKey and HotKeyCombination come from the package root. edytor.hotKeys is now the editor’s internal keymap; its Keymap class is not exported. Unknown chord tokens (cmd, esc, …) fail type-checking.
  • Block kinds declare defaultChild; the defaultBlock hook, Edytor.getDefaultBlock and Edytor.defaultType are gone (use edytor.defaultChild(parent)). Edytor.getBlockDefinition is replaced by edytor.definitionOf(type), which never throws: a kind no plugin registers renders as a plain block.
  • Plugin hooks run on the prepared command before anything is written; a veto refuses the whole command (a gesture over several blocks issues one command per part, see Refusing an edit). Several operation names changed, errors are reported as refused instead of thrown, and the first definition of a kind wins.
  • Marks render by tag (<strong data-edytor-mark="bold">), which changes CSS selectors. placeholder is a string or a function. HTML paste is built in.
  • A block selection holds exactly its members, and an emptied document shows a virtual paragraph.

See the editor and plugins sections for the current APIs.

Collaboration and providers

  • attachDocumentSync is removed: use document.attachSync. The presence format changed (one entry per view in selections), so old and new clients do not see each other’s carets.
  • createWebsocketSync takes { server, room }, the names of the <Edytor server room> props (serverUrl and roomName still work). It keeps a local IndexedDB copy by default (persist, persistName) and syncs tabs by default (disableBc). It no longer takes connect, protocols or resyncInterval; pass tokens in params.
  • WebsocketProvider lost the protocols option, the sync event (use synced) and wsconnecting (use the status event). It gained saved, unsaved, readOnly (the room’s read-only notice), the 'saved' event, the refused, expired and unreachable events, and the connectTimeout option.
  • IndexeddbPersistence lost get, set and del.
  • The document decides readiness itself: syncFailed, onSyncSettled and EdytorDocSyncPendingError are gone; see readiness. A document keeps one provider per target.
  • Content with a foreign schema makes the document read-only (document.writable) instead of throwing on every edit.
  • edytor/cloudflare is new: the Durable Object room replaces a coordinator you wrote yourself.
  • The provider sends the room id percent-encoded as one path segment (acme/roadmap dials …/rooms/acme%2Froadmap): a server that routes on the path must decode it. The ids . and .. throw, as a URL collapses those segments, and so do an empty id, one over 256 characters and one with a lone surrogate.

Migration API

  • MigrationRecord no longer has owner or leaseUntil, and is never stored as pending. The leaseMs, owner, pollMs and waitMs options are accepted and ignored.
  • force restores the v13 content in place instead of overwriting, and a store of another generation makes migrate throw GenerationMismatchError.

Upgrading between pre-releases

From 0.1.0-next.0

  • block.isEmpty and block.hasContent count inline atoms. A block holding only a mention is no longer empty; 0.0.11 and 0.1.0-next.0 counted text only. To test for text only, use block.content.every((part) => !(part instanceof Text) || part.isEmpty) (the old !hasContent; add && !block.children.length for the old isEmpty), with Text from edytor.
  • onDeleteSelectedBlocks runs whenever a block selection is replaced. Typing, a composition or a paste over a block selection now runs it first, as Backspace, Delete, a cut and the block menu’s Delete do: prevent() keeps the blocks and inserts nothing. A word or line delete (Alt, Mod or Ctrl with Backspace or Delete) over a block selection now deletes the blocks as Backspace does, hook first and children promoted; it used to skip the hook and delete them as a text range in Chromium (a selected parent alone kept its block and lost its text), and did nothing in Firefox and Safari.
  • Enter or Shift+Enter over a block selection edits the text, as in Notion. It leaves the selection and puts the caret at the end of the first selected block’s line, as Escape does; nothing changes, nothing is removed and onDeleteSelectedBlocks does not run. Enter used to empty the first selected block, keep it and remove the others (a checked to-do became an empty paragraph, a heading an empty heading), and Shift+Enter replaced the blocks with an empty paragraph holding a line break. Over selected dividers alone either key inserts a line after them (Enter did before; Shift+Enter replaced them). Over a selected image the caret now goes to the end of its caption (the image’s line), so text typed right after Enter goes into the caption and a second Enter adds the line; Enter used to insert a line after the image.
  • Formatting keys keep a block selection. Mod+B and the other mark keys over selected blocks format their text and leave the blocks selected, as in Notion; they used to turn the selection into a text range. Over selected dividers alone, the mark keys, Home, End and the word keys keep the dividers selected, and Escape puts the caret at the start of the next line (else the end of the line before); each used to put the caret inside the divider, where typing wrote hidden text into it. The word keys over a block selection that starts or ends in a divider, a list or a code block now land on its first or last shown line; they used to put the caret in that block’s hidden text too.
  • A composition over a block or inline-atom selection replaces it. An IME composition (Japanese, Chinese or Korean input) over selected blocks now starts in the one empty block that takes their place (a paragraph; inside a list, an item), and over a selected mention in its place, as typing does; it used to write its text at the start of the document’s first paragraph.
  • Mod+Enter never splits. As in Notion, it only modifies the blocks it is in: a toggle opens or closes, and with the rich text plugin a to-do checks or unchecks: each shown block among the selected ones (a to-do in a collapsed toggle stays), or the caret’s. Elsewhere it now does nothing, where it used to split the block at the end of the caret’s text node (moving a container’s children out, or an inline atom and the text after it to a new block). To keep a Mod+Enter action of your own, bind mod+enter in hotKeys.
  • Room ids are percent-encoded. A 0.1.0-next.0 client put the room id into the URL unchanged; since 0.1.0-next.1 the provider sends it through encodeURIComponent, so a:b dials …/rooms/a%3Ab. A Worker that decodes the segment, as the quick start does, routes both forms of an id to the same room unless it holds /, \, %, ?, #, a tab or a line break. A 0.1.0-next.0 client never reached its own room for such an id, as the URL rewrote it: / and \ (a ws: or wss: URL reads it as /) split the path (the quick start’s route answers 404), ? and # cut the id short (q?x opened room q), % read as an escape (a%41b opened aAb, and 50%off does not decode, so it is refused 4400), and a tab, LF or CR was stripped (a<tab>b opened ab). Upgraded clients reach the full id, so for those ids the two groups edit different rooms whatever the Worker does: reload every client onto the new version, then move what 0.1.0-next.0 clients wrote under the cut, decoded or stripped name to the full id. A Worker that routes on the raw segment (the last path segment, not decoded) sends an id holding $, %, &, +, ,, :, ;, =, @, [, ] or | (what encodeURIComponent escapes and a URL path keeps, so 50%off dials 50%25off; a space or a non-ASCII letter is escaped alike by both, and Chromium already escaped |) to a different, empty Durable Object, while clients still on 0.1.0-next.0 keep editing the old one. No edit is lost, but the two groups stop seeing each other’s edits.
  • What to do. Decode the segment in your Worker, closing one that does not decode with closedSocket(4400, 'invalid document id'), and deploy the Worker together with the upgraded clients. A raw-routing Worker stored an id the URL itself escapes (a space arrives as %20) under its escaped form, so after you add the decode those rooms open under the plain id: map the old names in the Worker, or move those documents.
  • User ids reach the room as authorize returns them. routeDocumentSocket now percent-encodes the userId into X-Edytor-User, so an id with a non-Latin-1 character (which a header could not carry) gets in, spaces around an id are kept (a header dropped them) and an id with a lone surrogate is closed 4403. An id authorize returned with spaces around it is now another user than the trimmed one its client ids were bound to, so those clients are refused (4409, or 1008 refused: replica when authorize passes no replica): trim the id in authorize to keep them.
  • edytor.moveBlocks with direction: 'in' or 'out' keeps the text in order. Siblings with another block between them now move as separate runs of adjacent siblings, as Tab and Shift+Tab do: [a, c] out of a list a to e leaves b between them, where it used to read b a c. A run that cannot move stays, and the result lists only the blocks that moved. The headless unNestBlocks(ids) still moves its blocks together (document API).
  • A throwing edytor.transact(fn) normalizes the writes it keeps. The normalization the writes before the throw requested now runs before the error leaves transact; it used to be skipped, leaving those writes committed and synced unnormalized.
  • <Edytor server room actor> names its local store without the trailing slashes of server. A view with an actor named its IndexedDB store from server as written, so wss://x/rooms/ and wss://x/rooms kept two stores (and two cross-tab channels, which the store carries); the socket, createWebsocketSync and a view without actor already removed them. An app whose server ends in / now opens a new store, edytor:<actor.id>@wss://x/rooms/<room>: what the room stored comes back from the room, but an edit made offline that never reached it stays in the old store. To keep the old store, build the sync with its old name: sync={createWebsocketSync({ server, room, params: dialParams, onExpired: (state) => refreshToken(state), persistName: `edytor:${actor.id}@${server}/${room}` })}, with server still ending in /. Once sync is passed, the view wires nothing itself: onSyncExpired does not apply (pass onExpired, which puts a fresh token in dialParams), params is read at each dial from the object you pass (keep one object and change its fields, as dialParams here; a new object is never seen), and a room id no URL can carry throws a TypeError instead of reaching onSyncRefused.
  • ., .. and lone surrogates are refused. A URL collapses those segments, and turns a lone surrogate into U+FFFD, so those ids never reached their room; the provider and createWebsocketSync now throw a TypeError for them (and for an empty id or one over 256 characters), and routeDocumentSocket closes them 4400. <Edytor server room> does not throw: an editable view gets the 4400 refusal through onSyncRefused without dialing, and a readonly view renders its value.

Complete list of changes

Every public change since 0.0.11, by area. The rows named in parentheses (del.blocks.promote, flow.*, …) are the contract rows of docs/editor-delete-contract.md in the repository.

Stored documents

  • Schema generation 4 (wire word 14004). Documents, IndexedDB containers and peers of earlier v14 development generations (1–3) are refused by the generation gate (GenerationMismatchError, schema-mismatch); re-import them. Behind the bumps: per-writer delete marks (a deleted block is not read as live), the replicated block nonce n (attribution records keyed by it), and text ownership as streams delimited by boundary items (the slices attribute is gone; merge claims live on claims).
  • v13 (yjs) documents still import through the migration.
  • Deterministic seeds. createDocument({ value }) and <Edytor {value}> write the value under a writer id hashed from it, so two replicas seeding the same template converge and a late identical seed never erases an edit. Missing ids are derived from that hash. Seed writer ids sit below 2^26, so a seed never displaces a block a live replica wrote. Seeded blocks carry no attribution stamp (createdBy is absent). BOOTSTRAP_BLOCK_ID is gone. See deterministic seeds.

Document and CRDT API (edytor, edytor/crdt/edytor)

  • Operation results. Every facade op and every document.block(id) mutator returns OpResult { status: 'applied' | 'noop' | 'refused', ids, reason? } instead of a boolean. An empty op (insertText(''), an empty range, moveBlocks([]), a same-value format) is noop and writes nothing; a reused child id is refused (id-collision); an absent target is refused. moveBlocks answers the moved ids in request order.
  • Two-phase operations. facade.prepare.<op>(…) returns a plan (steps, effect, ids) or the refusal without writing; facade.apply(plan) writes it in one transaction; facade.compose(...plans) joins plans prepared at one version. A plan applied at another version throws. New ops: prepare.deleteRange(start, end, view?), replaceRange(start, end, view?), deleteBlocks(ids), insertFlow(target, flow, view?), insertBlocks, setInlineData, liftOut(id, kind, { keep?, after? }) (places a block where a kind fits, out of every list it does not fit, as Turn into does); the optional view ({ hidden(id, removed?) }, RangeView) names the blocks a view hides, such as a closed toggle’s body, which a range leaves alone and a paste keeps with its first line, and insertFlow’s (FlowView) also takes itemKind(parent), a list’s flat item kind, whose pasted lines become the list’s items (see the document API); the editor passes it; positions are DocPosition { block, offset }. New helper: toBlockSpec(json, { freshIds? }).
  • Liveness. Deleting a block that is not live (already deleted, merged away, under a deleted parent) is refused (was true); blockText of a block under a deleted parent is null.
  • Order and capability. New facade.order(), compare(a, b), next(id, policy?), previous(id, policy?) (one document order; { sealed: true } never enters an island it did not start in), canPlace(ids, parent?), canMerge(from, into), defaultChild(parentId), rendersContent(type), fits(parent, kind) (the container rule: may a block of kind sit directly under parent) and nestParent(ids, parent) (where Tab nests them: parent, or a container’s last item). A list holds only its items: canPlace answers false and moveBlocks (and the moveBlock command) is refused where blocks would sit directly in a container they are no items of (a block already in it, such as an image a merge left there, still reorders among its items), and nestBlock into a list nests under its last item (refused when the list ends with a block that holds no children, such as an image); a paragraph stored directly in a list (an explicit write, a concurrent race) shows as its item.
  • Block roles as data. createDocument({ semantics }) takes block roles, content-less kinds and default children; defaultSemantics, richTextSemantics, codeSemantics and imageSemantics hold the bundled plugins’ rows and are deeply frozen. semanticsOf(...tables) (with the KindSemantics row type) builds a config from kind rows; to extend a bundled config, merge roles, rendersContent and defaultChild field by field, since a top-level spread drops the bundled rows (see the document API). Views still adopt roles from their plugins.
  • Change report. facade.onChange(cb) delivers one DocChange per commit that changed the visible document: { added, removed, moved, meta, content, order, origin, local, version }. A block that moves into a subtree added in the same commit is reported in moved/meta/content, not folded into the subtree.
  • Retired exports. The per-block subscriber API (subscribeBlock, subscribe, blockVersion, snapshot on the facade and the index), decorateRuns and its types (LocalDecoration, DecoratedRun), and the “advanced internals” tier: bindEdytorDoc, bindDocument, bindNodes, bindModel, bindRuns, bindIndexeddbProvider, bindWebsocketProvider, bindProviders, bindSync, bindAdmission, bindAttribution, bindBlockAttribution, bindMigration, bindLegacyReader, setDocRand/randOf, the rank functions, REGISTRY_KEY, SCHEMA, SCHEMA_NAME, the attribution root constants, migrationBcRoom, PREFERRED_TRIM_SIZE, GENERATION_PREFIX, generationDbName, GENERATION_KEY, writeProtocolVersion, removeAwarenessStates, outdatedTimeout, readAuthMessage, and their types. Use bindCrdt(Y) (.doc, .providers, .migration, .sync, .admission, .attribution) for engine injection, facade.onChange + facade.runs(id) for reads, and a kind’s transformText for local decorations.
  • Kept for a server coordinator (see the protocol): bindCrdt(Y), SCHEMA_VERSION, META_KEY, GENERATION, generationWord, frame, PROTOCOL_VERSION, readProtocolVersion, GENERATION_RECORD, GenerationMismatchError, the message types, the lib0 frame helpers, the awareness codec (readAwarenessEntries, writeAwarenessEntries, applyAwarenessUpdate, encodeAwarenessUpdate, modifyAwarenessUpdate) and messagePermissionDenied/writePermissionDenied. New: messageReadOnly/writeReadOnly, messageSaved, messageChunk, MAX_FRAME_BYTES, chunkFrame, createChunkReader.
  • Raw engine exports pruned (edytor/crdt, fork patch P8). Edytor never called these; they are removed from the vendored engine: the renderers (AttributionsRenderer, createAttributionsRenderer, DiffRenderer, createDiffRenderer, SnapshotRenderer, createSnapshotRenderer; AbstractRenderer and $renderer stay), snapshots (Snapshot, snapshot, createSnapshot, emptySnapshot, createDocFromSnapshot, encodeSnapshot(V2), decodeSnapshot(V2), equalSnapshots, snapshotContainsUpdate, nodeMapGetSnapshot, nodeMapGetAllSnapshot, and the snapshot argument of node.getAttrs), update helpers (logUpdate(V2), obfuscateUpdate(V2), diffUpdate — diffUpdateV2 stays —, encodeStateVectorFromUpdate(V2), convertUpdateFormatV1ToV2, createContentIdsFromUpdate(V2), intersectUpdateWithContentIds(V2), readUpdate, createDocFromUpdate(V2), cloneDoc, diffDocsToDelta, logNode), the delta-position helpers (createRelativePositionsFromDeltaPositions, createDeltaPositionsFromRelativePositions, createRelativePositionFromDeltaPosition, createDeltaPositionFromRelativePosition), the binary relative-position codec and comparison (encodeRelativePosition, decodeRelativePosition, compareRelativePositions; use relativePositionToJSON/createRelativePositionFromJSON), id-set/id-map algebra (gcIdSet, createInsertSetFromStructStore, encodeIdSet, decodeIdSet, mergeIdMaps, diffIdMap, intersectMaps, filterIdMap, createIdMapFromIdSet, createIdSetFromIdMap, $idMap), content-id helpers (createContentIds, createContentIdsFromContentMap, createContentIdsFromDoc, createContentIdsFromDocDiff, excludeContentIds, excludeContentMap, mergeContentIds, mergeContentMaps, createContentMapFromContentIds, intersectContentIds, intersectContentMap, filterContentMap, writeContentIds, readContentIds, encodeContentIds, decodeContentIds), and getNodeChildren, $node, getPathTo, tryGc, undoContentIds. A document built with createDocFromUpdate(u) is new Y.Doc() + Y.applyUpdate(doc, u); attribution.legacy() still returns a ContentMap (encodeContentMap/decodeContentMap, encodeIdMap/decodeIdMap stay) for a renderer you supply.
  • Anchors are { b, a }: the o owner facet and the a: -2 form are gone from the selection value, the presence wire and history entries.
  • Attribution. Undo and redo keep a block’s record (createdBy survives an undo/redo of its creation on every replica). A forced re-migration (force) gives a block whose stream started at a boundary a new nonce without moving its record, so its createdBy is replaced at the next attributed write.
  • facade.toJSON() inline atoms carry data: {} when they have no data (the shape edytor.value already had).
  • Block deletes promote children (del.blocks.promote). deleteBlocks(ids) and deleteBlock(id) delete only the named blocks: each one’s unselected children take its place (a deleted island’s children take the default child type). deleteBlock(id, { keepChildren: false }) is the whole-subtree delete (was the default). Promotion is read-time: a block a peer adds, moves or splits off under a concurrently deleted block also takes that block’s slot, so nothing is hidden with a deleted subtree.
  • Void blocks hold no children. Retyping a block to a void kind moves its children right after it; a child that still lands under a void block (a concurrent nest) is shown in its slot.
  • Undo of a creation withdraws (hist.undo.withdraw). The history no longer deletes a block node it created: it writes a per-writer withdraw mark wd.<writer> (fork patch P12, the UndoManager option withdraw(item, stackItem, transaction)), and the block is shown while it holds another writer’s content. hasBlock(id) stays true for a withdrawn block, and re-creating an undone id is refused. Old clients of the same schema generation ignore the marks and show a withdrawn block (empty, or with the other writer’s text); an old client’s own undo still deletes the node. A withdrawn block costs its node in storage (about 100 bytes) instead of a tombstone. A history created directly with new Y.UndoManager over the registry does not follow the rule.
  • Text delete marks. Every text delete writes a record naming its writer (root textdel, in the history’s scope) and every undo that brings text back writes a restoration record (root restored, never removed). Old clients of the same schema generation ignore both and keep the previous undo behavior (a double delete undone by both peers can show the character twice). A text delete update is about 21 bytes larger and a stored document about 12 bytes per delete. document.history is created by facade.createUndoManager as before; a history created directly with new Y.UndoManager over the registry does not follow the marks.
  • Conflicting type changes. An undo returns a block to the type you replaced, never to no type.

Editor view, selection and commands

  • value is not bindable. <Edytor value> is the initial content; bind:value fails type-checking. Read edits with onChange or edytor.value.
  • Component props. New: document (an EdytorDocument to render; several views can share one), actor (the local author), room, server and params (the view builds its IndexedDB copy and its websocket sync from them), onSyncExpired (the room closed with 4401; refresh params before the redial) and onSyncRefused (a refusal standing at mount, then each new one), blockHandles (blockDnd is its deprecated alias), defaultPlugins, and the root attributes translate, spellcheck, autocorrect, autocomplete, autocapitalize, inputmode and enterkeyhint. sync takes an EdytorSync (a factory from createIndexeddbSync/createWebsocketSync, or your own) and overrides room/server. hotKeys is typed (see Keymap below). See the component.
  • Handles. Block, Text and InlineBlock are id-only handles over the document index: getters read the document (inside a command they see its writes); they are not reactive — templates read the snippet’s view object or edytor.cells. new Block({block}), new Text({…content}) and new InlineBlock({block}) are gone: use block.insertChildren(index, JSONBlock[] | Block[]) (a handle moves) and block.insertParts(index, (JSONText | JSONInlineBlock)[]); deleteParts is removed (use deleteContentAtRange). A Text is the ordinal-th segment of its block (text.id = t:<block>:<ordinal>) with no identity across commits; key extension state by block id and anchor. Liveness: block.isInTree, text.isInDocument, atom.isInDocument (_live, _bound, _blockId, _segOrd, _items are gone; a dead block’s parent is undefined). edytor.idToBlock is the handle registry (get(id), block(id), text(blockId, ordinal), atom(blockId, atomId), parts(id)); edytor.idToText, edytor.idToInlineBlock, Edytor.getTextById, Block.partOffsetOf/atomOffsetOfPartIndex/projectedParts are removed (a text’s display offset is text.segStart, a block’s parts edytor.idToBlock.parts(id)). InlineBlock.type has no setter. Block.firstText/lastText are undefined for a kind that renders no content. New getters: Block.isContainer (a block that shows only its children, such as a list), Block.isListItem and Block.list (the list an item shows in); Block.convertible is false for a list container (its items convert).
  • Emptiness counts inline atoms. block.isEmpty (no content and no children) and block.hasContent count inline atoms as content, so a block holding only a mention is not empty (0.0.11 counted text only). To test for text only, use block.content.every((part) => !(part instanceof Text) || part.isEmpty) (the old !hasContent; add && !block.children.length for the old isEmpty).
  • Data writes are commands. block.setData(data), block.type = … and atom.setData(data) go through the dispatcher: refused on a readonly view, shown to onBeforeOperation (setBlock, or the new setInlineData for an atom), recorded in dispatcher.last, and each is its own undo step. block.model.setData is the raw write.
  • Definitions. edytor.definitionOf(type) is the one lookup and never throws (getBlockDefinition is removed). A kind no plugin registers renders as a plain block (its text and children), with one development warning per kind, instead of throwing. edytor.container is removed: use edytor.node.
  • Op results inside transactions. splitBlock, insertBlockAfter/Before, mergeBlock*, addInlineBlock and addChildBlock(s) answer handles of what they created, also inside an outer edytor.transact. A vetoed insertFlow answers [null, 0].
  • Selection is a value. selection.value (none, text { anchor, focus, pending? }, atom { blockId, atomId, from }, blocks { ids }), selection.select(value, cause) (the only writer), projection, epoch, cause, textValue, stage(marks)/pending. selection.state is a read-only view of the projection (assigning it fails; use setAtRange, setAtTextOffset or select): startText, endText, yStart, yEnd (offsets inside the text segments), startBlock, endBlock, texts, blocks, isCollapsed, isReversed, isBlockSpanning, isVoidEditableElement and edge (new). The projection facts moved to selection.projection: content (and its length), marks (was state.currentMarks), isAtStartOfText/isAtEndOfText/isAtStartOfBlock/isAtEndOfBlock, isTextSpanning, islandRoot/voidRoot (block ids; isIsland/isVoid are !== null); the caret anchors are the value’s (relativePosition/endPosition → selection.value.anchor/focus); contentParts, startNode, endNode and yTextContent are gone. setRangeStateAtTextOffsets → setAtRange (same arguments); setCollapsedStateAtTextOffset → setAtTextOffset (the old name is removed). Removed: hasSelectedAll, focusBlocks, deadEndpointRecoveryPending, notifyTextMounted, Edytor.getTextNode, ignoreNextSelectionChange, Edytor.mirrorRevision. setAtTextOffset takes a Text (not an id) and selects in the caller’s turn. onSelectionChange fires only when the value changed (a remote edit that moves the projection does not emit).
  • Pending marks are selection.pending / selection.stage(marks) (Text.markOnNextInsert is gone); a move clears them, an insertion at the caret consumes them, and they hold valued marks (links, colors) as a full set.
  • Default children. The plugin defaultBlock hook, Edytor.getDefaultBlock and Edytor.defaultType are removed: a kind declares defaultChild; edytor.defaultChild(parent) answers.
  • Deletion and paste follow one rule each (del.range.*, flow.*): blocks after the range end inside a dying container, and a dying tail’s children, survive; an emptied list container dies; a range from inside an island never merges out of it, and a range inside one lines island never removes it (its first line stays, emptied: a code block’s Mod+A, then Backspace, Delete or cut); a list or a table row never merges as a whole (canMerge refuses it): Delete at the end of the block above a list pulls the first item’s text up and the list keeps the rest, and Backspace at the start of a list’s first item lifts it out of the list, as its new parent’s default child; multi-line plain paste and drop split the block per line; copied fragments keep their ids and paste always mints fresh ones.
  • Moves. BlockMoveRequest is { blocks } & ({ target, position } | { direction: 'up' | 'down' | 'in' | 'out' }); BlockMoveDirection and the move types come from the package root (block/blockMove.js is deleted). A move is its own undo step, and a move that lands in or under a closed toggle opens it (edytor.moveBlocks and the block commands nestBlock, unNestBlock, moveBlock, moveBlocks too, as the handles and keys do). Arrow-move never nests; Mod+↑ moves a selected group; the handle claims every Alt+arrow. Outdent (Shift+Tab, unNestBlock, direction out) hands the following siblings to the outdented block, as in any outliner; an item of a list container leaves the list instead, without the items after it (a middle item splits the list).
  • Keymap. hotkeys.ts is deleted: the types move to the package root (HotKey, HotKeyCombination; HotKeyModifier, Single/DoubleModifierCombination removed); HotKeys → the keymap behind edytor.hotKeys, whose Keymap class is not exported (isHotkey → handle, run(chord), offered; no init). A HotKey payload’s event is optional. Chords take up to three modifiers in any order, plus punctuation and f1–f12; space matches the space bar. Unknown tokens (cmd, esc, return, …) fail type-checking and warn in development. The hotKeys prop is typed Partial<Record<HotKeyCombination, HotKey>>. A key binding runs once per occurrence.
  • Navigation. Shift+Home/End/PageUp/PageDown, Shift+↑/↓ and Mod+Shift+↑/↓ move the focus and keep the anchor (Mod+Shift+↑/↓ only where the default arrow move plugin has no block to move; otherwise it moves the caret’s block); word keys are visual under RTL; arrows skip a collapsed toggle’s body; an atom selection carries the side it was anchored on (from).
  • Input and IME. edytor.attempts (the input attempts) and edytor.composition (the session: live, phase, host, owns(node), ended(fn), restructured(change)) are new; edytor.isComposing is read-only. Removed: compositionState, compositionStartReplacementState, hasHandledCompositionInput, compositionText, resolveCompositionRegion, shouldIgnoreCompositionKeyDown, Text.toCompositionDomOffset, and the input-fallback flags and timers on Edytor. A composition is one undo step with its ending; no timer ends it; a commit that re-places the composing block (a peer deletes, retypes, moves or re-parents it) commits what the IME shows first. onBeforeInput no longer sees fabricated paste, drop or keydown-fallback events. preventUnsupportedDrop(event, edytor?).
  • History. edytor.historyUndo()/historyRedo() restore the selection each step recorded for this view (before on undo, after on redo); the undo-snapshot queue and its helpers are gone. The dispatcher owns undo grouping: structural edits, deletions, menu, toolbar and + actions, and markdown or slash conversions are each their own step, however soon they follow typing. See history.
  • Block selection is exactly its members (sel.blocks.exact). Select-all selects every block (was the root’s children); Shift+↑/↓ adds each block it passes; a block copy or cut carries the selected blocks only, a selected list or code block with everything in it (selection.selectedMembers; selectedBlocks holds the blocks as clicked). Deleting or cutting a block selection, or the block menu’s Delete, puts the caret at the start of a child promoted into its place, else at the end of the nearest line before it (else at the start of the nearest line after it), and Escape, Enter or Shift+Enter (which remove nothing) at the end of the first selected block’s line in document order. Mod+Enter only opens or closes toggles and checks to-dos (Notion); it no longer splits. Block.removeBlock() promotes the children (removeBlock({ keepChildren: false }) deletes the subtree).
  • Virtual paragraph (doc.empty.virtual). A view reads its document through a lens: edytor.facade is a per-view object whose prototype is document.facade, and edytor.facade.virtual() names the virtual paragraph while the document shows no block (edytor.root.children then holds its handle; edytor.value.children is empty). Every facade op the view issues on it creates it first, in the same plan. An emptied root no longer gets a replacement paragraph written by normalization.

Plugins and the extension API

  • Hooks run on the prepared command before any write. onBeforeOperation sees the command, then each planned step under its documented name (removeBlock, mergeBlockBackward, deleteContentAtRange, addChildBlocks, moveBlock(s), splitBlock, setBlock, insertText, addInlineBlock), with effect on the payload. A veto of any step refuses the whole command (no write, no undo step); a gesture over several blocks (Turn into, Tab, formatting, Duplicate) issues one command per part, so a veto skips only its part (Refusing an edit); prevent(cb) or a returned command replaces it once per extension; a payload returned for a nested step is ignored with a development warning. Nested operations and normalization are not shown as operations. onAfterOperation fires once per command. Commands the document refuses are still shown (no steps), so an extension may replace them.
  • onDeleteSelectedBlocks runs before every gesture that removes a block selection: Backspace or Delete (a word or line delete too), a cut, the block menu’s Delete, and typing, a composition or a paste over it (Enter, Shift+Enter and a drop remove nothing, so they never run it). prevent() keeps the blocks (a cut still writes the clipboard; typing and paste insert nothing) and dispatcher.last reports refused.
  • Operation names. New deleteBlocks ({ blocks }), insertFlow ({ flow, target }, paste and drop), insertDivider, setInlineData; deleteContentWithinSelection takes { replace } (was { preserveStartBlock }); Enter’s lift is splitBlock; markdown and slash conversions are setBlock with a deleteContentAtRange step; word deletes are deleteContentAtRange; handle in/out are moveBlock. Browser-typed text is adopted as insertText/deleteContentAtRange and can be vetoed (the text re-renders).
  • Errors. Top-level operations report refused (edytor.dispatcher.last.status) instead of throwing PreventionError; text operations refuse in readonly; a read-only (quarantined) document refuses instead of throwing SchemaMismatchError; async handler errors are reported, not swallowed. edytor.clear() is a command (one undo step, refused on a readonly view) and answers whether it applied; historyUndo() and historyRedo() set dispatcher.last too: applied, noop on an empty stack, refused on a readonly view.
  • Normalization (normalizeContent/normalizeChildren) runs at the end of the command’s transaction, inside it, with handles that read the command’s writes (at most 51 passes per normalizer and block); its work is part of the same update and undo step.
  • Definitions: first wins (was last): an extension that extends another’s definition lists itself first.
  • Kind records. BlockDefinition gains element, viewState, lines, rendersContent, defaultChild, itemKind, continues, container, presets, empty, html, plain, parse; snippet is optional (schema is removed). lines: true (with island and defaultChild) makes an island of lines, like the code block: its direct children show as its line kind and hold no children; a custom island of lines needs it, and an island without it keeps its children’s kinds and nesting (block roles in semantics take lines too). itemKind on a list container names the flat kind its items show as (numbered-list-item for ordered-list): the menus name an item by it, and turning an item into it keeps the item in its list. With empty, a conversion turns only a block that holds nothing into the kind; a block with text or children stays and the new block is inserted after it. MarkDefinition gains edge, toolbar, tag, attributes, parse; InlineBlockDefinition gains plain. convertToKind, KindRow, KindPreset are exported; edytor.kinds is the catalogue the slash menu, markdown shortcuts and menus read. One label per kind (Text, To-do list, Toggle list).
  • Snippets render inner markup. The core renders, registers and marks void the block element (use:block.attach and BlockView.attach/InlineBlockView.attach are removed; use:block.void still marks inner chrome). Block snippets receive block: BlockView = { id, type, data, selected, focused, handle, void }; inline-atom snippets block: InlineBlockView = { id, type, data, selected, handle } (handle is undefined for a suggested atom). Commands and document reads go through block.handle. onBlockAttached runs once per element, and the attach hooks may return a cleanup. Identity attributes are declarative (present in server-rendered HTML).
  • transformText receives declared values { text: { stringContent, value }, block: { id, type, data }, content }, not handles.
  • Marks at insertion follow one rule (explicit → a replaced range’s common marks → pending → the neighbour → the mark’s edge); insertText without marks resolves them by that rule; plain paste inherits the caret’s marks; Mod+B at the start of a bold run toggles bold off.
  • Suggestions. Block.suggestions is plain JSON parts (rawSuggestions and the readonly Proxy wrappers are removed); suggestText stores [atom, [text runs]] groups.
  • HTML import is core. The HTML paste plugin (never exported) and its hand-written parser are gone; external HTML paste and drop are imported by the core through the browser’s parser and the records (parse hooks on kind and mark records; see clipboard). A plugin’s onPaste still runs first. The code plugin highlights through TanStack Highlight, and the package declares sideEffects so it (and its CSS) ships only in apps that import codePlugin.
  • Notion behavior. Markdown > makes a toggle and " a quote; + , a. , i. and inline markdown are new, and a prefix converts at the caret, keeping the text after it. The to-do checkbox writes data.checked. The callout’s default icon is 💡 (was !). Headings are h1–h3; a missing level is h1 and any other renders and copies as h3. Lists, to-dos and toggles continue on Enter, toggles, callouts and quotes are containers, and Backspace at the start of a kind other than its parent’s default child turns it into that default first: see Enter and Backspace by role. The paragraph and code block elements no longer carry Tailwind classes. KindPreset.group and EditorCommand.hint are new; richTextOperations(edytor).removeMarkAtRange(mark) is new.
  • Default plugins. <Edytor> appends arrowMovePlugin, imagePlugin and richTextPlugin, in that order, after your plugins unless the list already has them, and prepends block handles; defaultPlugins={false} opts out. Your plugins therefore win over all three; to let a default win over one of yours, list it yourself before your plugin. Passing plugins={[richTextPlugin]} still works.
  • Image plugin. imagePlugin, createImagePlugin({ upload }) and ImagePluginOptions are exported. The image kind stores data.src, has an “Image” preset in the Media group, and exports and imports <figure><img><figcaption>.
  • UI snippets. createSlashMenuPlugin({ item, menu }), createToolbarPlugin({ toolbar }), createBlockMenuPlugin({ menu }) and BlockHandlesOptions.handle are new, with the types SlashMenuOptions, SlashMenuItem, SlashMenuController, ToolbarOptions, ToolbarController, BlockMenuController, BlockMenuAction, BlockHandleSnippetPayload and BlockHandleController. The toolbar and block menu place the element marked data-edytor-toolbar-bar / data-edytor-block-menu (was their data-testid).
  • Other: the slash menu opens only at the start of a text or after whitespace, matches word prefixes of labels and keywords, claims Enter only with a match, and closes when a non-empty query matches nothing or the query starts with a space; a replacing kind (divider, image, code) converts a block in place only when the block holds nothing but the slash query, and is otherwise inserted after it; a hotkey whose value is not a function is skipped with a development warning; the code plugin’s Shift+Enter runs insertParagraph as an intent, its auto-pair is a returned payload (after-hooks see the typed character), and a typed closer steps over the same character.

Rendering, placeholder and chrome

  • placeholder (prop, option, plugin) is a string or (view: { type, data, focused, empty }) => string | null; the snippet form is gone. It renders as data-placeholder on the empty text element with a shipped ::before rule (style [data-placeholder]::before); [data-edytor-text-placeholder] and its click handlers are gone. edytor.placeholderRepair is removed; edytor.placeholderAt(id) is new.
  • Chrome lives in [data-edytor-overlay], a sibling after the host: block handles (in document order), the drop indicator (position: absolute, not in document.body), menus (position: fixed inside the layer) and layer-relative remote carets. --edytor-handle-offset-y is gone. edytor.overlay (layer, add(measure), mount(…), invalidate()) is new.
  • Typing, formatting, Tab and block moves never remount the element under the caret; a moved block’s element is re-created where it moved. edytor.cells, edytor.pin, edytor.surface (the DOM observer), edytor.projector, textAt, atomAt, segmentOf, deltasOf are new; refreshEditorDom/editorDomRevision are removed.
  • The DOM observer restores what the editor owns and leaves an extension’s markup around the slots alone: a foreign node inside a block’s text run is removed, a node beside it stays; foreign text inside a content is adopted; attributes the core does not own (an extension’s id on a block element, open on a toggle) are never reverted.
  • A “[data-edytor-block] exists” check no longer means hydrated; wait for a registered text element.
  • Marks by tag. A mark record declares tag (and attributes from its value); the core renders the tag itself as the mark element (<strong data-edytor-mark="bold">, was <span data-edytor-mark="bold"><b>), and the clipboard exports the same element. MarkDefinition.html is replaced by tag/attributes; snippet is optional (a snippet still renders inside a core <span data-edytor-mark>). Built-ins: bold strong, italic em, underline u, strike s, code code, link a (sanitized href, target), superscript sup, subscript sub, color and highlight span with a sanitized style. Selectors such as [data-edytor-mark="link"] a become a[data-edytor-mark="link"].

Collaboration and providers

  • Presence wire. selections[viewKey] = { start, end, collapsed, reversed, t } for text (DocAnchors), { blocks, t } for a block set, { atom, block, t } for an atom; the legacy selection mirror, startTextId/yStart fields and numeric fallbacks are gone. A view writes only its own key and clears it on destroy. publishPresence replaces createAwarenessSelection/publishAwarenessSelection/clearAwarenessSelection; attachDocumentSync is removed (use document.attachSync). A lost socket clears remote carets in that tab only.
  • createWebsocketSync takes server, room, params, maxBackoffTime, connectTimeout, onExpired, WebSocketPolyfill, disableBc, persist and persistName (connect, protocols and resyncInterval are removed; serverUrl/roomName are deprecated aliases of server/room). It persists locally by default: an IndexedDB store beside the socket (persist: false opts out, persistName renames it from edytor:<server>/<room>; the returned sync’s persistName is the name to pass to clearDocument), skipped where there is no indexedDB, attached through the new EdytorSyncPayload.attach. While the store exists it carries the cross-tab channel; disableBc turns both off (IndexeddbPersistence gains a disableBc option). connectTimeout (default 10 000 ms, Infinity for no limit) closes a dial that neither opens nor fails, like a failed one. The room name is percent-encoded into one path segment of the URL (encodeURIComponent), so a server that routes on the raw path must decode it: acme/roadmap dials …/rooms/acme%2Froadmap. An id a URL cannot carry ('', ., .., over 256 characters, or with a lone surrogate) throws a TypeError at construction. onExpired(state) runs on each 4401 close ({ reason, attempts, nextRetryMs }): put a fresh token in params before the redial.
  • WebsocketProvider: removed the protocols option and field (pass tokens in params, read at every dial), the sync event (use synced), wsconnecting (the status event carries it) and the settle window (syncSettleMs). resyncInterval stays a provider option only. The BroadcastChannel leg stays and is on by default (disableBc opts out); it relays other tabs’ edits to the server. New: saved, unsaved and the 'saved' event (the room’s messageSaved acknowledgements), readOnly (true once the room’s read-only notice arrives, with no event; a read-only socket counts nothing as unsaved, so check readOnly to learn that edits were never stored: see saved state), messageChunk reassembly, the refused event (a SyncRefusedError on a 1008 close or a 4xxx close other than 4401, after which it stops dialing), the expired event (a 4401 close: expired credentials; the provider redials after the backoff with the re-read params), the unreachable event (the backoff cap grows to 30 s after 8 dials that never synced) and the connectTimeout option. A room fault (1011) after a sync also grows the backoff, until the room acknowledges every local update again. The provider sends a text ping after 15 s of silence, and a text pong counts as liveness.
  • Readiness. The document decides readiness itself: syncFailed, onSyncSettled and EdytorDocSyncPendingError are gone; a lone first client is ready after DEFAULT_READINESS_BOUND (1 s, per factory EdytorSync.bound; createWebsocketSync arms it from the socket’s open or first failed dial through the new EdytorSyncPayload.armBound); history refuses while pending. A refusal never seeds: document.syncRefusal and document.onSyncRefused(fn) are new. A refusal belongs to the provider that reported it: releasing that provider, or its next sync, lifts it. EdytorSyncPayload.holdBound() stops a provider’s bound until the next armBound(): a 4401 before the first sync holds it, so an empty document waits for a dial that gets in instead of seeding. whenSynced exists on both providers and synced is the lifetime claim.
  • One provider per target. EdytorSync.target (indexeddb:<name>, websocket:<server>/<room>); attaching a target already attached is a no-op returning nothing; attaching on a destroyed document throws DocumentDestroyedError.
  • Admission. Unversioned content is not refused at ingress: the document is read-only and quarantined until admission accepts it (document.writable, onWritableChange).
  • IndexeddbPersistence: get/set/del are removed (the custom store holds only the generation record).
  • edytor/cloudflare (new). DocumentRoom, attachDocument and routeDocumentSocket(request, namespace, documentId, authorize) replace the coordinator you wrote yourself; see the room. The entry also exports AttachedDocument (what attachDocument returns: the socket handlers plus transact, read, compact, dropWaitingDeletes, reset and the diagnostics; forward the ones you call over RPC), SOCKET_TAG, noTimers, ROOM_ORIGIN (the origin of the room’s own edits), IDENTITY_HEADERS, the defaults DEFAULT_MAX_ROW_BYTES, DEFAULT_COMPACT_AFTER and DEFAULT_SAVE_AFTER, the caps MAX_REFUSALS and MAX_WAITING_DELETES (the delete ranges the room keeps waiting for items it does not hold), and the types AttachDocumentOptions, Attachment, DocumentRoomEnv, LoadedDocument, Refusal, ReplicaOwner, SavedDocument, SocketIdentity, AuthorizeDocumentSocket, ExpiredCredential, DocumentNamespace and DocumentIdentity. requestedReplica(request) reads the dial’s client id for authorize, and closedSocket(code, reason) turns a dial away from your own Worker with a close the provider understands (an HTTP error reaches the browser as 1006, which it redials forever). The room binds { user, replica, readOnly } to each socket, strips the content of updates under another user’s client ids, and applies defaultSemantics to server edits. Its close codes: 4400 for an empty document id, ., .., one over 256 characters or one with a lone surrogate (authorize is not called; the provider stops), 4403 when authorize returns null (the provider stops), 4401 when it returns { expired: true } (ExpiredCredential; the provider redials), 4409 for a replica id another user holds, 1008 for undecodable bytes or a stored document the room refuses (refused: container), and 1011 for a fault of the room itself (retried).
  • SSR. <Edytor> releases the document it created in onDestroy, which also runs after a server render.

Migration

  • MigrationRecord loses owner and leaseUntil and is never stored pending; MigrateOptions.leaseMs/owner/pollMs/waitMs and waitForSettled’s options are accepted no-ops (a crash-released navigator.locks lock arbitrates; status() reports pending, wait: false returns busy). MigrationPhase loses activate/announce; there are no BroadcastChannel migration announcements. force restores legacy ids in place instead of overwriting a snapshot row; result.update is the full migrated state; a foreign-generation container makes migrate throw GenerationMismatchError. bindCrdt(Y).doc.restore is new.

Was this page helpful?