docs/plans/2026-05-28-slate-yjs-current-architecture-operation-matrix.md
Date: 2026-05-28
Status: active
Owner skill: .agents/skills/task/SKILL.md
Supersedes:
docs/plans/2026-05-13-slate-v2-yjs-core-readiness-ralplan.mddocs/plans/2026-05-13-yjs-collaboration-harvest.mddocs/plans/2026-05-18-slate-yjs-package-readiness-ralplan.mddocs/plans/2026-05-25-slate-yjs-structural-operation-coverage-ralplan.mdThe job is no longer "create packages/slate-yjs from empty residue." Current
../slate-v2 already has a real first-party @slate/yjs package with source,
package metadata, a React subpath, an example route, package tests, and
Playwright tests.
The correct next move is a hard architecture reset inside that package:
createYjsExtension(...), createYjsController(...), state.yjs, tx.yjs, ./core, ./react, ./internalStrong call: do not keep growing src/core/index.ts as the package brain. It is
already doing document serialization, op encoding, remote import, history repair,
selection binding, awareness, lifecycle, and React-facing state in one file.
That is how collaboration bugs hide.
Read on 2026-05-28 from ../slate-v2:
packages/slate-yjs/package.jsonpackages/slate-yjs/src/core/index.tspackages/slate-yjs/src/index.tspackages/slate-yjs/src/internal/index.tspackages/slate-yjs/src/react/index.tsxpackages/slate-yjs/test/core-contract.tssite/examples/ts/yjs-collaboration.tsxplaywright/integration/examples/yjs-collaboration.test.tspackages/slate/src/interfaces/operation.tspackages/slate/src/interfaces/editor.tspackages/slate-history/src/history-extension.tsdocs/editor-test-harvester/yjs-collaboration/{report,inventory,test-index}.mddocs/solutions/developer-experience/2026-05-13-slate-v2-yjs-readiness-needs-core-contracts-before-package-work.mddocs/solutions/test-failures/2026-03-22-yjs-slow-tests-need-explicit-bun-paths-and-bootstrapped-shared-types.mdCurrent architecture:
@slate/yjs, version 0.0.0, with exports ., ./core,
./internal, and ./react.REMOTE_IMPORT_OPTIONS with collaboration tags, history
skip, and selection focus/scroll suppression.Y.XmlElement and contiguous
Slate text leaves as one Y.XmlText with slate:text-leaves metadata.Y.RelativePosition arrays.Y.UndoManager, stack item metadata, and local Slate history
bridging.applySlateOperationsToYjs(...) currently supports insert_text,
remove_text, set_node, set_selection, insert_node, and
replace_children.remove_node, merge_node, move_node, split_node, and
replace_fragment are not explicitly handled in the current switch observed
at packages/slate-yjs/src/core/index.ts:1009-1087.writeLocalSnapshot(...) falls back to
writeSlateValueToYjsUnchecked(...), which calls replaceYjsChildren(...)
and deletes/reinserts root children.mark-bold, split-node, insert-text,
wrap-node, insert-fragment, and move, that are not present in the
current example source.move_node, but it cannot be
advertised as stable moved-subtree identity.Y.XmlText and Y.XmlElement
instances whenever possible.move_node cannot claim stable moved-subtree CRDT semantics until it preserves
the existing Yjs node identity through the move or introduces a tested
identity/proxy design.Keep public exports stable, but split internals:
| File | Owns |
|---|---|
packages/slate-yjs/src/core/index.ts | public core exports only |
packages/slate-yjs/src/core/controller.ts | extension lifecycle, connect/pause/resume/disconnect |
packages/slate-yjs/src/core/document.ts | Slate value <-> Yjs document serialization |
packages/slate-yjs/src/core/operations.ts | operation encoder registry and traceable fallback policy |
packages/slate-yjs/src/core/history.ts | Yjs UndoManager bridge and Slate history repair |
packages/slate-yjs/src/core/selection.ts | Slate range <-> Y.RelativePosition mapping |
packages/slate-yjs/src/core/awareness.ts | awareness state and remote cursor projection |
packages/slate-yjs/src/core/testing.ts | package-local test helpers if repeated fixtures get noisy |
Do this split as execution work only when it directly helps the operation matrix. No ornamental refactor.
Every row must get four tests:
local-offline: operation applies while the peer is disconnectedconcurrent-remote: another peer edits related or adjacent contentreconnect-recovery: peers exchange real Y.encodeStateAsUpdate /
Y.applyUpdate updates and convergelocal-undo-redo: local undo/redo removes only local intent and preserves
remote edits| Surface | Current state | Required encoder / classification | Required tests |
|---|---|---|---|
insert_text | encoded incrementally | keep bounded Y.XmlText.insert; add unicode/marks rows | 4 scenarios |
remove_text | encoded incrementally | keep bounded Y.XmlText.delete; prove deleted text with concurrent inserts | 4 scenarios |
insert_node | encoded incrementally | preserve parent/window insert; prove nested element and text-leaf cases | 4 scenarios |
remove_node | missing from switch | hide target Yjs child or remove one text leaf without root rewrite | 4 scenarios |
split_node | verify current live support before trusting old plan | split text metadata or element children without root rewrite | 4 scenarios |
merge_node | missing from switch | merge into surviving previous sibling and hide absorbed node | 4 scenarios |
move_node | missing from switch | P1 clone+hide only for outside-subtree convergence; P2 stable identity design for moved-subtree edits | 4 scenarios plus explicit limitation |
set_node | encoded incrementally | cover element props, text marks, and unset-as-set semantics | 4 scenarios |
unset_node public transform | no operation kind; maps through set_node | characterize emitted operations, then cover as set_node clearing props | 4 transform scenarios |
replace_children | encoded incrementally | keep child-window replacement and hidden old containers | 4 scenarios |
replace_fragment | missing from switch | encode as scoped child-window replacement under operation.path | 4 scenarios |
insert_fragment public transform | emits operation batches, not a standalone op | characterize exact emitted ops, then cover resulting replace_fragment/replace_children/text ops | 4 transform scenarios |
delete_fragment public command | command surface, not current Operation union | characterize emitted ops from browser/user path; no invented op kind | 4 transform scenarios |
wrapNodes / tx.nodes.wrap | composed transform | characterize emitted op sequence and require no silent snapshot | 4 transform scenarios |
unwrapNodes / tx.nodes.unwrap | composed transform | characterize emitted op sequence and require no silent snapshot | 4 transform scenarios |
liftNodes / tx.nodes.lift | composed transform | characterize emitted op sequence and require no silent snapshot | 4 transform scenarios |
Completion rule: the matrix is not complete until every row has all four scenario lanes or an explicit unsupported decision with a failing-safe behavior.
Add a package-local collaboration harness in packages/slate-yjs/test/:
Y.Doc instancesY.encodeStateAsUpdate / Y.applyUpdateDo not compare only local Slate values. The proof must go through real Yjs updates.
Operation['type'] is either supported,
unsupported, or snapshot-only-explicit.remove_node.
merge_node.replace_fragment.split_node against live source.move_node P1.
insert_text, remove_text, insert_node, set_node,
replace_children into the four-scenario matrix.unsetNodesinsertFragmentdeleteFragmentwrapNodesunwrapNodesliftNodesFile: ../slate-v2/site/examples/ts/yjs-collaboration.tsx
The example must become a simulator with:
Browser tests should use real user editing paths when a browser path exists: typing, Backspace, Enter, selection delete, paste/insert fragment, and toolbar buttons that call the public editor transforms.
File: ../slate-v2/playwright/integration/examples/yjs-collaboration.test.ts
Required groups:
Use /examples/yjs-collaboration unless a standalone block route is added.
From ../slate-v2:
bun test ./packages/slate-yjs/test/core-contract.ts
bun --filter @slate/yjs build
bun --filter @slate/yjs typecheck
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium
bun lint:fix
If package exports or file layout changes, run the repo barrel/update command
required by the current ../slate-v2 package tooling.
Release artifact: this changes a published package under packages/, so a
changeset is required before completion.
move_node has two possible definitions:
P1 is enough to close the current destructive fallback bug class. P2 is required before claiming full moved-subtree collaboration correctness. Do not blur those.
Start with implementation Phase 1 and Phase 2:
remove_node four-scenario package testDo not implement the whole matrix in one shot. That would create beautiful garbage.