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. migrateverifies 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.updateholds the migrated state;loadDocument(result.update)opens it headlessly, andresult.jsonholds 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 }).loadDocumentrefuses v13 bytes withUnsupportedDocError(reasonlegacy), andisLegacyDoc(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
valueis deterministic, so replicas seeding the same template converge.BOOTSTRAP_BLOCK_IDis gone, and seeded blocks have nocreatedBy.
Document API
- Every facade operation returns an
OpResult(applied,nooporrefused) 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 theBlockSpecthat inserts take. - New queries:
order,compare,next,previous,canPlace,canMerge,defaultChild,fits,nestParent. facade.onChangereports oneDocChangeper commit. The per-block subscription API (subscribeBlock,subscribe,blockVersion,snapshot),decorateRunsand thebind*building blocks are no longer exported: useonChangewithruns(id), a block kind’stransformTextfor decorations, andbindCrdt(Y)for engine injection.deleteBlock(id)anddeleteBlocks(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)staystrue, and re-creating an undone id is refused. - A document edited without a view knows no block roles unless you pass
semantics(defaultSemanticsfor the bundled kinds). - Selection anchors are
{ b, a };toJSON()gives inline blocksdata: {}when they have none. duplicateBlock(id, freshId)andblock(id).duplicate(freshId)callfreshId(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
valueis the initial content and is no longer bindable:bind:valuefails type-checking. Follow edits withonChangeoredytor.value.Block,TextandInlineBlockare non-reactive handles over the document, and their constructors are gone. Snippets receive aBlockView({ id, type, data, selected, focused, handle, void }), not aBlock: commands go throughblock.handle.- Selection is a value set with
selection.select(),setAtRangeorsetAtTextOffset. The marks at the caret areselection.projection.marks(wasselection.state.currentMarks); marks for the next insertion areselection.stage(marks)/selection.pending(text.markOnNextInsertis gone). hotkeys.tsand theHotKeysclass are gone. Declare bindings in thehotKeysprop or a plugin’shotkeys(see Hotkeys);HotKeyandHotKeyCombinationcome from the package root.edytor.hotKeysis now the editor’s internal keymap; itsKeymapclass is not exported. Unknown chord tokens (cmd,esc, …) fail type-checking.- Block kinds declare
defaultChild; thedefaultBlockhook,Edytor.getDefaultBlockandEdytor.defaultTypeare gone (useedytor.defaultChild(parent)).Edytor.getBlockDefinitionis replaced byedytor.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
refusedinstead of thrown, and the first definition of a kind wins. - Marks render by
tag(<strong data-edytor-mark="bold">), which changes CSS selectors.placeholderis 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
attachDocumentSyncis removed: usedocument.attachSync. The presence format changed (one entry per view inselections), so old and new clients do not see each other’s carets.createWebsocketSynctakes{ server, room }, the names of the<Edytor server room>props (serverUrlandroomNamestill work). It keeps a local IndexedDB copy by default (persist,persistName) and syncs tabs by default (disableBc). It no longer takesconnect,protocolsorresyncInterval; pass tokens inparams.WebsocketProviderlost theprotocolsoption, thesyncevent (usesynced) andwsconnecting(use thestatusevent). It gainedsaved,unsaved,readOnly(the room’s read-only notice), the'saved'event, therefused,expiredandunreachableevents, and theconnectTimeoutoption.IndexeddbPersistencelostget,setanddel.- The document decides readiness itself:
syncFailed,onSyncSettledandEdytorDocSyncPendingErrorare 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/cloudflareis new: the Durable Object room replaces a coordinator you wrote yourself.- The provider sends the room id percent-encoded as one path segment (
acme/roadmapdials…/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
MigrationRecordno longer hasownerorleaseUntil, and is never stored aspending. TheleaseMs,owner,pollMsandwaitMsoptions are accepted and ignored.forcerestores the v13 content in place instead of overwriting, and a store of another generation makesmigratethrowGenerationMismatchError.
Upgrading between pre-releases
From 0.1.0-next.0
block.isEmptyandblock.hasContentcount 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, useblock.content.every((part) => !(part instanceof Text) || part.isEmpty)(the old!hasContent; add&& !block.children.lengthfor the oldisEmpty), withTextfromedytor.onDeleteSelectedBlocksruns 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
onDeleteSelectedBlocksdoes 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+enterinhotKeys. - 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, soa:bdials…/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\(aws:orwss:URL reads it as/) split the path (the quick start’s route answers 404),?and#cut the id short (q?xopened roomq),%read as an escape (a%41bopenedaAb, and50%offdoes not decode, so it is refused4400), and a tab, LF or CR was stripped (a<tab>bopenedab). 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|(whatencodeURIComponentescapes and a URL path keeps, so50%offdials50%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
authorizereturns them.routeDocumentSocketnow percent-encodes theuserIdintoX-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 closed4403. An idauthorizereturned with spaces around it is now another user than the trimmed one its client ids were bound to, so those clients are refused (4409, or1008refused: replicawhenauthorizepasses noreplica): trim the id inauthorizeto keep them. edytor.moveBlockswithdirection: '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 listatoeleavesbbetween them, where it used to readb a c. A run that cannot move stays, and the result lists only the blocks that moved. The headlessunNestBlocks(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 leavestransact; it used to be skipped, leaving those writes committed and synced unnormalized. <Edytor server room actor>names its local store without the trailing slashes ofserver. A view with anactornamed its IndexedDB store fromserveras written, sowss://x/rooms/andwss://x/roomskept two stores (and two cross-tab channels, which the store carries); the socket,createWebsocketSyncand a view withoutactoralready removed them. An app whoseserverends 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}` })}, withserverstill ending in/. Oncesyncis passed, the view wires nothing itself:onSyncExpireddoes not apply (passonExpired, which puts a fresh token indialParams),paramsis read at each dial from the object you pass (keep one object and change its fields, asdialParamshere; a new object is never seen), and a room id no URL can carry throws aTypeErrorinstead of reachingonSyncRefused..,..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 andcreateWebsocketSyncnow throw aTypeErrorfor them (and for an empty id or one over 256 characters), androuteDocumentSocketcloses them4400.<Edytor server room>does not throw: an editable view gets the4400refusal throughonSyncRefusedwithout dialing, and a readonly view renders itsvalue.
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 noncen(attribution records keyed by it), and text ownership as streams delimited by boundary items (theslicesattribute is gone; merge claims live onclaims). - 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 (createdByis absent).BOOTSTRAP_BLOCK_IDis gone. See deterministic seeds.
Document and CRDT API (edytor, edytor/crdt/edytor)
- Operation results. Every facade op and every
document.block(id)mutator returnsOpResult{ status: 'applied' | 'noop' | 'refused', ids, reason? }instead of a boolean. An empty op (insertText(''), an empty range,moveBlocks([]), a same-value format) isnoopand writes nothing; a reused child id isrefused(id-collision); an absent target isrefused.moveBlocksanswers 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 optionalview({ 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, andinsertFlow’s (FlowView) also takesitemKind(parent), a list’s flat item kind, whose pasted lines become the list’s items (see the document API); the editor passes it; positions areDocPosition { 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);blockTextof a block under a deleted parent isnull. - 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 ofkindsit directly underparent) andnestParent(ids, parent)(where Tab nests them:parent, or a container’s last item). A list holds only its items:canPlaceanswersfalseandmoveBlocks(and themoveBlockcommand) 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), andnestBlockinto 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,codeSemanticsandimageSemanticshold the bundled plugins’ rows and are deeply frozen.semanticsOf(...tables)(with theKindSemanticsrow type) builds a config from kind rows; to extend a bundled config, mergeroles,rendersContentanddefaultChildfield 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 oneDocChangeper 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 inmoved/meta/content, not folded into the subtree. - Retired exports. The per-block subscriber API (
subscribeBlock,subscribe,blockVersion,snapshoton the facade and the index),decorateRunsand 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. UsebindCrdt(Y)(.doc,.providers,.migration,.sync,.admission,.attribution) for engine injection,facade.onChange+facade.runs(id)for reads, and a kind’stransformTextfor 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) andmessagePermissionDenied/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;AbstractRendererand$rendererstay), snapshots (Snapshot,snapshot,createSnapshot,emptySnapshot,createDocFromSnapshot,encodeSnapshot(V2),decodeSnapshot(V2),equalSnapshots,snapshotContainsUpdate,nodeMapGetSnapshot,nodeMapGetAllSnapshot, and thesnapshotargument ofnode.getAttrs), update helpers (logUpdate(V2),obfuscateUpdate(V2),diffUpdate—diffUpdateV2stays —,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; userelativePositionToJSON/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), andgetNodeChildren,$node,getPathTo,tryGc,undoContentIds. A document built withcreateDocFromUpdate(u)isnew Y.Doc()+Y.applyUpdate(doc, u);attribution.legacy()still returns aContentMap(encodeContentMap/decodeContentMap,encodeIdMap/decodeIdMapstay) for a renderer you supply. - Anchors are
{ b, a }: theoowner facet and thea: -2form are gone from the selection value, the presence wire and history entries. - Attribution. Undo and redo keep a block’s record (
createdBysurvives 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 itscreatedByis replaced at the next attributed write. facade.toJSON()inline atoms carrydata: {}when they have no data (the shapeedytor.valuealready had).- Block deletes promote children (
del.blocks.promote).deleteBlocks(ids)anddeleteBlock(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 markwd.<writer>(fork patch P12, theUndoManageroptionwithdraw(item, stackItem, transaction)), and the block is shown while it holds another writer’s content.hasBlock(id)staystruefor 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 withnew Y.UndoManagerover 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 (rootrestored, 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.historyis created byfacade.createUndoManageras before; a history created directly withnew Y.UndoManagerover 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
valueis not bindable.<Edytor value>is the initial content;bind:valuefails type-checking. Read edits withonChangeoredytor.value.- Component props. New:
document(anEdytorDocumentto render; several views can share one),actor(the local author),room,serverandparams(the view builds its IndexedDB copy and its websocket sync from them),onSyncExpired(the room closed with4401; refreshparamsbefore the redial) andonSyncRefused(a refusal standing at mount, then each new one),blockHandles(blockDndis its deprecated alias),defaultPlugins, and the root attributestranslate,spellcheck,autocorrect,autocomplete,autocapitalize,inputmodeandenterkeyhint.synctakes anEdytorSync(a factory fromcreateIndexeddbSync/createWebsocketSync, or your own) and overridesroom/server.hotKeysis typed (see Keymap below). See the component. - Handles.
Block,TextandInlineBlockare 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 oredytor.cells.new Block({block}),new Text({…content})andnew InlineBlock({block})are gone: useblock.insertChildren(index, JSONBlock[] | Block[])(a handle moves) andblock.insertParts(index, (JSONText | JSONInlineBlock)[]);deletePartsis removed (usedeleteContentAtRange). ATextis theordinal-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,_itemsare gone; a dead block’sparentisundefined).edytor.idToBlockis the handle registry (get(id),block(id),text(blockId, ordinal),atom(blockId, atomId),parts(id));edytor.idToText,edytor.idToInlineBlock,Edytor.getTextById,Block.partOffsetOf/atomOffsetOfPartIndex/projectedPartsare removed (a text’s display offset istext.segStart, a block’s partsedytor.idToBlock.parts(id)).InlineBlock.typehas no setter.Block.firstText/lastTextareundefinedfor a kind that renders no content. New getters:Block.isContainer(a block that shows only its children, such as a list),Block.isListItemandBlock.list(the list an item shows in);Block.convertibleisfalsefor a list container (its items convert). - Emptiness counts inline atoms.
block.isEmpty(no content and no children) andblock.hasContentcount 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, useblock.content.every((part) => !(part instanceof Text) || part.isEmpty)(the old!hasContent; add&& !block.children.lengthfor the oldisEmpty). - Data writes are commands.
block.setData(data),block.type = …andatom.setData(data)go through the dispatcher: refused on a readonly view, shown toonBeforeOperation(setBlock, or the newsetInlineDatafor an atom), recorded indispatcher.last, and each is its own undo step.block.model.setDatais the raw write. - Definitions.
edytor.definitionOf(type)is the one lookup and never throws (getBlockDefinitionis 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.containeris removed: useedytor.node. - Op results inside transactions.
splitBlock,insertBlockAfter/Before,mergeBlock*,addInlineBlockandaddChildBlock(s)answer handles of what they created, also inside an outeredytor.transact. A vetoedinsertFlowanswers[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.stateis a read-only view of the projection (assigning it fails; usesetAtRange,setAtTextOffsetorselect):startText,endText,yStart,yEnd(offsets inside the text segments),startBlock,endBlock,texts,blocks,isCollapsed,isReversed,isBlockSpanning,isVoidEditableElementandedge(new). The projection facts moved toselection.projection:content(and itslength),marks(wasstate.currentMarks),isAtStartOfText/isAtEndOfText/isAtStartOfBlock/isAtEndOfBlock,isTextSpanning,islandRoot/voidRoot(block ids;isIsland/isVoidare!== null); the caret anchors are the value’s (relativePosition/endPosition→selection.value.anchor/focus);contentParts,startNode,endNodeandyTextContentare gone.setRangeStateAtTextOffsets→setAtRange(same arguments);setCollapsedStateAtTextOffset→setAtTextOffset(the old name is removed). Removed:hasSelectedAll,focusBlocks,deadEndpointRecoveryPending,notifyTextMounted,Edytor.getTextNode,ignoreNextSelectionChange,Edytor.mirrorRevision.setAtTextOffsettakes aText(not an id) and selects in the caller’s turn.onSelectionChangefires only when the value changed (a remote edit that moves the projection does not emit). - Pending marks are
selection.pending/selection.stage(marks)(Text.markOnNextInsertis 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
defaultBlockhook,Edytor.getDefaultBlockandEdytor.defaultTypeare removed: a kind declaresdefaultChild;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 onelinesisland 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 (canMergerefuses 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.
BlockMoveRequestis{ blocks } & ({ target, position } | { direction: 'up' | 'down' | 'in' | 'out' });BlockMoveDirectionand the move types come from the package root (block/blockMove.jsis deleted). A move is its own undo step, and a move that lands in or under a closed toggle opens it (edytor.moveBlocksand the block commandsnestBlock,unNestBlock,moveBlock,moveBlockstoo, as the handles and keys do). Arrow-move never nests;Mod+↑moves a selected group; the handle claims everyAlt+arrow. Outdent (Shift+Tab,unNestBlock, directionout) 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.tsis deleted: the types move to the package root (HotKey,HotKeyCombination;HotKeyModifier,Single/DoubleModifierCombinationremoved);HotKeys→ the keymap behindedytor.hotKeys, whoseKeymapclass is not exported (isHotkey→handle,run(chord),offered; noinit). AHotKeypayload’seventis optional. Chords take up to three modifiers in any order, plus punctuation andf1–f12;spacematches the space bar. Unknown tokens (cmd,esc,return, …) fail type-checking and warn in development. ThehotKeysprop is typedPartial<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) andedytor.composition(the session:live,phase,host,owns(node),ended(fn),restructured(change)) are new;edytor.isComposingis read-only. Removed:compositionState,compositionStartReplacementState,hasHandledCompositionInput,compositionText,resolveCompositionRegion,shouldIgnoreCompositionKeyDown,Text.toCompositionDomOffset, and the input-fallback flags and timers onEdytor. 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.onBeforeInputno 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;selectedBlocksholds 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.facadeis a per-view object whose prototype isdocument.facade, andedytor.facade.virtual()names the virtual paragraph while the document shows no block (edytor.root.childrenthen holds its handle;edytor.value.childrenis 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.
onBeforeOperationsees the command, then each planned step under its documented name (removeBlock,mergeBlockBackward,deleteContentAtRange,addChildBlocks,moveBlock(s),splitBlock,setBlock,insertText,addInlineBlock), witheffecton 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.onAfterOperationfires once per command. Commands the document refuses are still shown (no steps), so an extension may replace them. onDeleteSelectedBlocksruns 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) anddispatcher.lastreportsrefused.- Operation names. New
deleteBlocks({ blocks }),insertFlow({ flow, target }, paste and drop),insertDivider,setInlineData;deleteContentWithinSelectiontakes{ replace }(was{ preserveStartBlock }); Enter’s lift issplitBlock; markdown and slash conversions aresetBlockwith adeleteContentAtRangestep; word deletes aredeleteContentAtRange; handle in/out aremoveBlock. Browser-typed text is adopted asinsertText/deleteContentAtRangeand can be vetoed (the text re-renders). - Errors. Top-level operations report
refused(edytor.dispatcher.last.status) instead of throwingPreventionError; text operations refuse in readonly; a read-only (quarantined) document refuses instead of throwingSchemaMismatchError; 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()andhistoryRedo()setdispatcher.lasttoo:applied,noopon an empty stack,refusedon 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.
BlockDefinitiongainselement,viewState,lines,rendersContent,defaultChild,itemKind,continues,container,presets,empty,html,plain,parse;snippetis optional (schemais removed).lines: true(withislandanddefaultChild) 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 insemanticstakelinestoo).itemKindon a list container names the flat kind its items show as (numbered-list-itemforordered-list): the menus name an item by it, and turning an item into it keeps the item in its list. Withempty, 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.MarkDefinitiongainsedge,toolbar,tag,attributes,parse;InlineBlockDefinitiongainsplain.convertToKind,KindRow,KindPresetare exported;edytor.kindsis 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.attachandBlockView.attach/InlineBlockView.attachare removed;use:block.voidstill marks inner chrome). Block snippets receiveblock: BlockView={ id, type, data, selected, focused, handle, void }; inline-atom snippetsblock: InlineBlockView={ id, type, data, selected, handle }(handleisundefinedfor a suggested atom). Commands and document reads go throughblock.handle.onBlockAttachedruns once per element, and the attach hooks may return a cleanup. Identity attributes are declarative (present in server-rendered HTML). transformTextreceives 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);insertTextwithout 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.suggestionsis plain JSON parts (rawSuggestionsand the readonly Proxy wrappers are removed);suggestTextstores[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 (
parsehooks on kind and mark records; see clipboard). A plugin’sonPastestill runs first. The code plugin highlights through TanStack Highlight, and the package declaressideEffectsso it (and its CSS) ships only in apps that importcodePlugin. - 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 writesdata.checked. The callout’s default icon is💡(was!). Headings areh1–h3; a missing level ish1and any other renders and copies ash3. 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.groupandEditorCommand.hintare new;richTextOperations(edytor).removeMarkAtRange(mark)is new. - Default plugins.
<Edytor>appendsarrowMovePlugin,imagePluginandrichTextPlugin, in that order, after yourpluginsunless 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. Passingplugins={[richTextPlugin]}still works. - Image plugin.
imagePlugin,createImagePlugin({ upload })andImagePluginOptionsare exported. Theimagekind storesdata.src, has an “Image” preset in theMediagroup, and exports and imports<figure><img><figcaption>. - UI snippets.
createSlashMenuPlugin({ item, menu }),createToolbarPlugin({ toolbar }),createBlockMenuPlugin({ menu })andBlockHandlesOptions.handleare new, with the typesSlashMenuOptions,SlashMenuItem,SlashMenuController,ToolbarOptions,ToolbarController,BlockMenuController,BlockMenuAction,BlockHandleSnippetPayloadandBlockHandleController. The toolbar and block menu place the element markeddata-edytor-toolbar-bar/data-edytor-block-menu(was theirdata-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
insertParagraphas 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 asdata-placeholderon the empty text element with a shipped::beforerule (style[data-placeholder]::before);[data-edytor-text-placeholder]and its click handlers are gone.edytor.placeholderRepairis 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 indocument.body), menus (position: fixedinside the layer) and layer-relative remote carets.--edytor-handle-offset-yis 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,deltasOfare new;refreshEditorDom/editorDomRevisionare 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
idon a block element,openon 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(andattributesfrom 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.htmlis replaced bytag/attributes;snippetis optional (a snippet still renders inside a core<span data-edytor-mark>). Built-ins: boldstrong, italicem, underlineu, strikes, codecode, linka(sanitizedhref,target), superscriptsup, subscriptsub, color and highlightspanwith a sanitizedstyle. Selectors such as[data-edytor-mark="link"] abecomea[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 legacyselectionmirror,startTextId/yStartfields and numeric fallbacks are gone. A view writes only its own key and clears it on destroy.publishPresencereplacescreateAwarenessSelection/publishAwarenessSelection/clearAwarenessSelection;attachDocumentSyncis removed (usedocument.attachSync). A lost socket clears remote carets in that tab only. createWebsocketSynctakesserver,room,params,maxBackoffTime,connectTimeout,onExpired,WebSocketPolyfill,disableBc,persistandpersistName(connect,protocolsandresyncIntervalare removed;serverUrl/roomNameare deprecated aliases ofserver/room). It persists locally by default: an IndexedDB store beside the socket (persist: falseopts out,persistNamerenames it fromedytor:<server>/<room>; the returned sync’spersistNameis the name to pass toclearDocument), skipped where there is noindexedDB, attached through the newEdytorSyncPayload.attach. While the store exists it carries the cross-tab channel;disableBcturns both off (IndexeddbPersistencegains adisableBcoption).connectTimeout(default 10 000 ms,Infinityfor 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/roadmapdials…/rooms/acme%2Froadmap. An id a URL cannot carry ('',.,.., over 256 characters, or with a lone surrogate) throws aTypeErrorat construction.onExpired(state)runs on each4401close ({ reason, attempts, nextRetryMs }): put a fresh token inparamsbefore the redial.WebsocketProvider: removed theprotocolsoption and field (pass tokens inparams, read at every dial), thesyncevent (usesynced),wsconnecting(thestatusevent carries it) and the settle window (syncSettleMs).resyncIntervalstays a provider option only. The BroadcastChannel leg stays and is on by default (disableBcopts out); it relays other tabs’ edits to the server. New:saved,unsavedand the'saved'event (the room’smessageSavedacknowledgements),readOnly(trueonce the room’s read-only notice arrives, with no event; a read-only socket counts nothing as unsaved, so checkreadOnlyto learn that edits were never stored: see saved state),messageChunkreassembly, therefusedevent (aSyncRefusedErroron a1008close or a4xxxclose other than4401, after which it stops dialing), theexpiredevent (a4401close: expired credentials; the provider redials after the backoff with the re-readparams), theunreachableevent (the backoff cap grows to 30 s after 8 dials that never synced) and theconnectTimeoutoption. A room fault (1011) after a sync also grows the backoff, until the room acknowledges every local update again. The provider sends a textpingafter 15 s of silence, and a textpongcounts as liveness.- Readiness. The document decides readiness itself:
syncFailed,onSyncSettledandEdytorDocSyncPendingErrorare gone; a lone first client is ready afterDEFAULT_READINESS_BOUND(1 s, per factoryEdytorSync.bound;createWebsocketSyncarms it from the socket’s open or first failed dial through the newEdytorSyncPayload.armBound);historyrefuses whilepending. A refusal never seeds:document.syncRefusalanddocument.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 nextarmBound(): a4401before the first sync holds it, so an empty document waits for a dial that gets in instead of seeding.whenSyncedexists on both providers andsyncedis 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 throwsDocumentDestroyedError. - 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/delare removed (thecustomstore holds only the generation record).edytor/cloudflare(new).DocumentRoom,attachDocumentandrouteDocumentSocket(request, namespace, documentId, authorize)replace the coordinator you wrote yourself; see the room. The entry also exportsAttachedDocument(whatattachDocumentreturns: the socket handlers plustransact,read,compact,dropWaitingDeletes,resetand 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 defaultsDEFAULT_MAX_ROW_BYTES,DEFAULT_COMPACT_AFTERandDEFAULT_SAVE_AFTER, the capsMAX_REFUSALSandMAX_WAITING_DELETES(the delete ranges the room keeps waiting for items it does not hold), and the typesAttachDocumentOptions,Attachment,DocumentRoomEnv,LoadedDocument,Refusal,ReplicaOwner,SavedDocument,SocketIdentity,AuthorizeDocumentSocket,ExpiredCredential,DocumentNamespaceandDocumentIdentity.requestedReplica(request)reads the dial’s client id forauthorize, andclosedSocket(code, reason)turns a dial away from your own Worker with a close the provider understands (an HTTP error reaches the browser as1006, 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 appliesdefaultSemanticsto server edits. Its close codes:4400for an empty document id,.,.., one over 256 characters or one with a lone surrogate (authorizeis not called; the provider stops),4403whenauthorizereturnsnull(the provider stops),4401when it returns{ expired: true }(ExpiredCredential; the provider redials),4409for a replica id another user holds,1008for undecodable bytes or a stored document the room refuses (refused: container), and1011for a fault of the room itself (retried).- SSR.
<Edytor>releases the document it created inonDestroy, which also runs after a server render.
Migration
MigrationRecordlosesownerandleaseUntiland is never storedpending;MigrateOptions.leaseMs/owner/pollMs/waitMsandwaitForSettled’s options are accepted no-ops (a crash-releasednavigator.lockslock arbitrates;status()reportspending,wait: falsereturnsbusy).MigrationPhaselosesactivate/announce; there are no BroadcastChannel migration announcements.forcerestores legacy ids in place instead of overwriting a snapshot row;result.updateis the full migrated state; a foreign-generation container makesmigratethrowGenerationMismatchError.bindCrdt(Y).doc.restoreis new.