---
title: Migration
description: Import documents stored by the v13 (yjs) engine, and upgrade code written for edytor 0.0.11.
icon: circle-arrow-up
---

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.

```ts src/lib/migrate.ts check
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.
}
```

```svelte
<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](#complete-list-of-changes) 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`](/docs/reference/document-api#results) (`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](/docs/customization/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](/docs/plugins/operations#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](/docs/collaboration/concurrent-editing#an-emptied-document-shows-a-virtual-paragraph).

See the [editor](/docs/editor/edytor-component) and [plugins](/docs/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](/docs/collaboration/documents#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](/docs/server/quick-start) 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 <kbd>Backspace</kbd>, <kbd>Delete</kbd>, a cut and the block menu's Delete do: `prevent()` keeps the blocks and inserts nothing. A word or line delete (<kbd>Alt</kbd>, <kbd>Mod</kbd> or <kbd>Ctrl</kbd> with <kbd>Backspace</kbd> or <kbd>Delete</kbd>) over a block selection now deletes the blocks as <kbd>Backspace</kbd> 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.
- **<kbd>Enter</kbd> or <kbd>Shift</kbd>+<kbd>Enter</kbd> 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 <kbd>Escape</kbd> does; nothing changes, nothing is removed and `onDeleteSelectedBlocks` does not run. <kbd>Enter</kbd> 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 <kbd>Shift</kbd>+<kbd>Enter</kbd> replaced the blocks with an empty paragraph holding a line break. Over selected dividers alone either key inserts a line after them (<kbd>Enter</kbd> did before; <kbd>Shift</kbd>+<kbd>Enter</kbd> 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 <kbd>Enter</kbd> goes into the caption and a second <kbd>Enter</kbd> adds the line; <kbd>Enter</kbd> used to insert a line after the image.
- **Formatting keys keep a block selection.** <kbd>Mod</kbd>+<kbd>B</kbd> 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, <kbd>Home</kbd>, <kbd>End</kbd> and the word keys keep the dividers selected, and <kbd>Escape</kbd> 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.
- **<kbd>Mod</kbd>+<kbd>Enter</kbd> 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](/docs/server/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 <kbd>Tab</kbd> and <kbd>Shift+Tab</kbd> 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](/docs/reference/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](#importing-v13-documents).
- **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](/docs/collaboration/documents#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](/docs/reference/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](/docs/reference/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](/docs/server/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](/docs/editor/edytor-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](/docs/plugins/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](/docs/editor/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, <kbd>Enter</kbd> or <kbd>Shift</kbd>+<kbd>Enter</kbd> (which remove nothing) at the end of the first selected block's line in document order. <kbd>Mod</kbd>+<kbd>Enter</kbd> 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, <kbd>Tab</kbd>, formatting, Duplicate) issues one command per part, so a veto skips only its part ([Refusing an edit](/docs/plugins/operations#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: <kbd>Backspace</kbd> or <kbd>Delete</kbd> (a word or line delete too), a cut, the block menu's Delete, and typing, a composition or a paste over it (<kbd>Enter</kbd>, <kbd>Shift</kbd>+<kbd>Enter</kbd> 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](/docs/editor/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](/docs/customization/hotkeys#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 (`DocAnchor`s), `{ 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](/docs/collaboration/websocket#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](/docs/server/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.
