docs/plans/2026-05-25-slate-yjs-structural-operation-coverage-ralplan.md
Date: 2026-05-25 Status: done Current pass: closure final gates complete Next pass: none
Sync note, 2026-05-28: this plan is still useful for the structural fallback bug class, but it is narrower than the current request. Use
docs/plans/2026-05-28-slate-yjs-current-architecture-operation-matrix.mdfor the full operation/transform matrix and four collaboration scenarios.
@slate/yjs is still not operation-complete. The latest split_node fix closed
the Enter/offline-reconnect regression, but the adapter still falls back to
full-document Yjs snapshot writes for these Slate operations:
merge_noderemove_nodereplace_fragmentmove_nodeThat fallback is unsafe for collaboration because
writeSlateValueToYjsUnchecked deletes every visible root child before inserting
the caller's local snapshot. It can make the local peer look correct while
poisoning later Yjs merges.
The next implementation must be TDD-first. Do not patch implementation until the first red test proves one user-visible conflict.
Intent: finish the Slate operation coverage holes that can still turn local structural edits into destructive Yjs root snapshots.
Desired outcome: every normal Slate editing operation generated by current Slate v2 user paths either has an operation-level Yjs encoder or is explicitly classified as snapshot-only with a non-user-edit reason.
In scope:
packages/slate-yjs/test/core-contract.ts cases first.merge_node, remove_node,
replace_fragment, and move_node.Non-goals:
Decision boundaries:
@slate/yjs helpers and metadata.createYjsExtension(...), state.yjs, and tx.yjs.Current operation coverage:
packages/slate-yjs/src/core/index.ts:1246-1324 supports
insert_text, remove_text, set_node, set_selection, insert_node,
replace_children, and split_node; default returns false.packages/slate-yjs/src/core/index.ts:2293-2298 falls back to
writeSlateValueToYjsUnchecked when the operation-level encoder returns
false.packages/slate-yjs/src/core/index.ts:305-324 shows the fallback deletes all
current Yjs children, then inserts a new serialized value.packages/slate/src/interfaces/operation.ts:36-159 defines the missing
operation kinds as part of the current Operation union.packages/slate/test/operations-contract.ts:249-320 and
packages/slate/test/snapshot-contract.ts:2777-2885 prove current raw Slate
move_node and merge_node semantics.packages/slate/test/collab-bookmark-position-contract.ts:101-205 proves raw
Slate remote replay already handles remove_node, merge_node, and
move_node bookmark/range rebasing.Prior @slate/yjs evidence:
../slate-v2/docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md
says the root cause of the latest lost-edit bug was split_node falling back
to full-document snapshot writes.../slate-v2/docs/solutions/logic-errors/yjs-offline-replace-undo-concurrent-append-2026-05-25.md
says replace/delete-style edits should hide existing Yjs containers instead of
deleting them when undo or concurrent inserts may need those containers alive.External implementation evidence:
../slate-yjs/packages/core/src/applyToYjs/node/index.ts:10-17 maps every
legacy Slate node operation, including remove_node, merge_node,
move_node, and split_node.../slate-yjs/packages/core/src/applyToYjs/node/removeNode.ts:5-15 encodes
remove as a targeted parent delete, not root replacement.../slate-yjs/packages/core/src/applyToYjs/node/mergeNode.ts:13-82 merges by
appending the target delta into the previous target, then deleting the
absorbed target range.../slate-yjs/packages/core/src/applyToYjs/node/moveNode.ts:12-57 encodes
move as targeted delete plus insert delta.../lexical/packages/lexical-yjs/src/SyncV2.ts:9-24 records the same core
shape as the current package: sibling text nodes share one XmlText, and
non-text nodes map one-to-one to XmlElement.../lexical/packages/lexical-yjs/src/SyncV2.ts:849-867 deletes/inserts only
the changed child window, not the whole root.../y-prosemirror/src/sync-utils.js:388-394 computes a before/after diff to
avoid losing delete operations during transaction-to-Yjs conversion.Principles:
Top drivers:
@slate/yjs.Viable options:
move_node cannot honestly preserve moved-subtree concurrent edits
without extra identity metadata.split_node bug class. Drop.Chosen option: option 1, with an explicit guard that unsupported operation types cannot silently snapshot.
Rejected alternative: clone root snapshots after every structural op. That is the bug, not a fix.
Consequences:
merge_node, remove_node, and replace_fragment should be treated as P1.move_node gets a safe first-stage encoder that avoids root replacement.Add tests one at a time. Each test must fail before implementation and pass after only the matching operation encoder is added.
merge_node Preserves A Concurrent Edit In The Surviving Left BranchFile: packages/slate-yjs/test/core-contract.ts
Name:
it('merges disconnected merge_node with a concurrent text edit in the surviving branch', async () => {})
Setup:
[paragraph('alpha'), paragraph('beta')].{ type: 'merge_node', path: [1], position: 1, properties: { type: 'paragraph' }, root: 'main' }.! at { path: [0, 0], offset: 5 }.Expected:
alpha!beta.alpha or stale two-paragraph snapshot.Why it fails now:
merge_node hits default -> false, so B writes a whole local snapshot and
deletes/reinserts root children.Green target:
remove_node Preserves A Concurrent Edit Outside The Removed NodeFile: packages/slate-yjs/test/core-contract.ts
Name:
it('merges disconnected remove_node with a concurrent edit outside the removed node', async () => {})
Setup:
[paragraph('alpha'), paragraph('beta')].{ type: 'remove_node', path: [1], node: paragraph('beta'), root: 'main' }.! at the end of alpha.Expected:
[paragraph('alpha!')].beta stays hidden/removed.! survives.Green target:
Y.XmlText leaves.replace_fragment Does Not Rewrite Sibling BranchesFile: packages/slate-yjs/test/core-contract.ts
Name:
it('merges disconnected replace_fragment with a concurrent sibling edit', async () => {})
Setup:
[paragraph('alpha'), paragraph('beta')].path: [0]:
replace the paragraph's child text leaf with { text: 'omega' }.! at the end of second paragraph beta.Expected:
[paragraph('omega'), paragraph('beta!')].Green target:
replace_fragment delegates to the same child-window machinery as
replace_children, but with index: 0 and children.length under
operation.path.move_node Avoids Root Snapshot And Preserves Concurrent Sibling EditsFile: packages/slate-yjs/test/core-contract.ts
Name:
it('merges disconnected move_node with a concurrent edit in an unmoved sibling', async () => {})
Setup:
[paragraph('alpha'), paragraph('beta'), paragraph('gamma')].{ type: 'move_node', path: [1], newPath: [0], root: 'main' }.! at the end of gamma.Expected:
[paragraph('beta'), paragraph('alpha'), paragraph('gamma!')].Green target:
move_node is targeted delete plus targeted insert/clone, not
root snapshot.Non-claim:
File: packages/slate-yjs/src/core/index.ts
Shape:
type SlateYjsOperationType = Operation['type']
const SLATE_YJS_OPERATION_TYPES = {
insert_text: true,
remove_text: true,
set_node: true,
set_selection: true,
insert_node: true,
remove_node: true,
merge_node: true,
move_node: true,
split_node: true,
replace_children: true,
replace_fragment: true,
} satisfies Record<SlateYjsOperationType, true>
Expected:
@slate/yjs has no
decision for it.Add browser rows after the core red/green loop, not before:
Run:
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium --grep "merge|remove|move"
Add internal helpers near the existing replace_children / split_node
helpers:
hideYjsSlateChild(parent, slateIndex) using DELETED_ATTRIBUTE.removeYjsTextLeaf(sharedRoot, path).replaceYjsChildRange(parent, index, oldLength, newChildren).cloneYjsNodeForInsert(node) reusing the current cloneYjsChild behavior.getPreviousYjsSlateSibling(sharedRoot, path).Keep helpers internal. No public package API.
remove_nodeText path:
getYjsTextLeaves.readYjsText(sharedText).setYjsTextLeaves.Element path:
DELETED_ATTRIBUTE.parent.delete(...) for normal user remove. Keeping the container
alive is consistent with the replace-children undo/concurrency fix.merge_nodeText path:
setYjsTextLeaves.Element path:
operation.properties describes removed
node properties and should not overwrite the survivor.replace_fragmentoperation.path".replaceYjsChildRange(parent, 0, operation.children.length, operation.newChildren).replace_children.move_nodeFirst-stage support:
path equals newPath.newPath semantics from raw Slate.Required limitation row:
Replace the silent default: return false pattern with explicit operation-type
coverage. Snapshot fallback may still occur when an encoder returns false
for invalid paths, but not because the operation kind was forgotten.
Trigger: collaboration operation encoding and conflict behavior. This is high-risk because a wrong encoder can silently corrupt shared Yjs state while each local Slate editor appears correct.
Blast radius:
packages/slate-yjs/src/core/index.tspackages/slate-yjs/test/core-contract.tsplaywright/integration/examples/yjs-collaboration.test.tsexamples/yjs-collaboration or the current Yjs example route if browser rows
need real-user controls#5771, #5533, #1770, #2288, #3741,
#2881, #3551, and #3715Failure scenarios:
remove_node hides a text container too broadly and drops adjacent text
leaves. This would look like a successful local delete, then lose remote text
after sync.merge_node incorrectly applies removed-node properties to the survivor,
diverging from raw Slate.move_node is presented as true CRDT move support and later loses
concurrent edits inside the moved subtree.replace_fragment uses the wrong child count and deletes siblings outside
the intended replacement window.false for an ordinary valid
user operation.Backspace, selection delete, paste/fragment replacement).Proof plan:
move_node: first-stage clone+hide support
only claims preservation of concurrent edits outside the moved subtree.Expanded proof matrix:
| Surface | Required proof | Blocks closure if missing |
|---|---|---|
| Unit/core | One red-green convergence test per operation: merge_node, remove_node, replace_fragment, move_node | Yes |
| Integration/Yjs | Disconnected peer updates exchange through real Y.encodeStateAsUpdate / Y.applyUpdate, not direct Slate value comparison only | Yes |
| Undo/history | Local undo after reconnect does not throw, does not become stale enabled no-op, and does not remove remote-only edits | Yes for touched operation families |
| Browser | Real-user Backspace merge and block delete rows through Playwright | Yes |
| Browser selection | Selection/cursor rows only claim closure when the browser test reproduces the user path, not just memory sync | Yes for any #5771 stronger claim |
| Public API | createYjsExtension(...), state.yjs, and tx.yjs call sites stay stable | Yes |
| Performance | No supported user operation may call whole-root writeSlateValueToYjsUnchecked; encoders mutate bounded parent/window state | Yes |
| Docs/example | Add docs/example notes only if the example UI changes; keep current-state docs, not changelog prose | Conditional |
| Issue ledger | No promotion to Fixes #5771 without package tests plus browser collaboration proof | Yes |
Rollback / remediation:
move_node needs true moved-subtree preservation, split it into a separate
stable-identity plan. Do not stretch clone+hide into a false CRDT claim.Improves / Related and hand off the browser failure as the next owner.Verdict: keep and revise. The plan is sound only with two hard constraints:
move_node remains first-stage bounded support, and no operation kind can enter
snapshot fallback because it was forgotten. Anything stronger is bullshit until
stable identity and browser proof exist.
| System | Source | Mechanism | Avoids | Steal | Reject | Slate target | Verdict |
|---|---|---|---|---|---|---|---|
legacy slate-yjs | ../slate-yjs/packages/core/src/applyToYjs/node/index.ts:10-17 | explicit mapper for every node op | forgotten op kinds | operation-level mapper coverage | legacy editor monkey-patching | internal v2 encoder coverage guard | agree |
legacy slate-yjs | ../slate-yjs/packages/core/src/applyToYjs/node/mergeNode.ts:13-82 | targeted merge delta | root replacement | targeted parent mutation | old single XmlText root assumption | v2 Y.XmlElement/Y.XmlText helper equivalents | partial |
| Lexical Yjs | ../lexical/packages/lexical-yjs/src/SyncV2.ts:9-24 | text siblings share XmlText, element nodes map to XmlElement | per-character object churn | current v2 document shape | Lexical node class/state model | preserve existing @slate/yjs representation | agree |
| Lexical Yjs | ../lexical/packages/lexical-yjs/src/SyncV2.ts:849-867 | delete/insert only changed child window | whole-root churn | bounded child-window replacement | app-specific node mapping | use replace_children-style hidden ranges | agree |
| y-prosemirror | ../y-prosemirror/src/sync-utils.js:388-394 | final before/after diff avoids losing deletes | fragile step composition | prove delete/move with replayed final docs | ProseMirror schema fitting in raw Slate | use tests to validate final convergence | partial |
| Lens | Applicability | Finding | Plan delta |
|---|---|---|---|
tdd | applied | One red test per operation family; no horizontal "write all tests first" implementation. | Red 1-4 are ordered vertical slices. |
performance-oracle | applied | Operation encoders must mutate bounded parent windows, not serialize whole documents. | Add complexity note: each op targets parent/window size, not root size. |
vercel-react-best-practices | skipped | Core Yjs encoder work has no React render/subscription change. | Browser proof still required because example behavior changes. |
react-useeffect | skipped | No effect/subscription API change planned. | None. |
shadcn | skipped | No UI/component API change in this planning slice. | None. |
Current status: related-issue-discovery complete; full issue-ledger pass complete.
This plan touches collaboration behavior and therefore needs the related issue
discovery pass before closure. Initial ledger read found the current PR
reference already keeps Yjs/collaboration readiness at Improves #5771 and
does not claim a fixed issue. Keep that stance until the implementation passes
unit and browser proof.
Related issue discovery result:
| Issue | Current ledger status | Relation to this plan | Decision |
|---|---|---|---|
#5771 | Improves | Collaboration selection/anchor failures are the nearest issue-facing pressure. Current ledger already says exact provider/browser closure is unclaimed until real adapter/browser proof exists. | Keep Improves; this plan may strengthen the @slate/yjs proof route but must not promote to Fixes without real browser collaboration proof. |
#5533 | Related | The plan improves the first-party Yjs binding, not Yjs-free collaboration. | Keep Related. |
#1770 | Related | Operation-composition pressure is relevant because full-document snapshot fallback destroys op intent. | Keep Related; this plan is not a general operation-composition utility. |
#2288 | Improves | replace_fragment / child-window replacement relates to range-shaped operations. | Keep existing Improves; no public range-operation API claim. |
#3741 | Related | move_node collaboration metadata/payload pressure is directly relevant. | Keep Related; first-stage move_node encoder does not add moved-node payloads or exact OT closure. |
#2881 | Related / cluster-synced | split_node payload pressure is already partly answered by the prior split-node Yjs encoder, but this plan does not alter raw Slate op payloads. | No new claim. |
#3551 | Fixes in existing ledger | Move undo wrong-state is already fixed by slate-history proof, not by this Yjs package slice. | Preserve existing fixed claim; this plan must not rewrite that claim. |
#3715 | docs/example only | Collaboration example/docs pressure is adjacent if browser rows add new controls. | Related only if examples change; no issue claim in this planning pass. |
Issue discovery evidence:
docs/slate-issues/gitcrawl-live-open-ledger.md lists current open rows for
#5771, #5533, #1770, #3741, #2881, #3715, and #3551.docs/slate-issues/gitcrawl-v2-sync-ledger.md:93 keeps #5771 at
Improves, explicitly withholding fixed/provider/browser closure.docs/slate-issues/gitcrawl-v2-sync-ledger.md:178, :276, :279,
:530, and :635 keep #5533, #1770, #2288, #3741, and #2881
in their existing collaboration/op-model classifications.docs/slate-v2/ledgers/issue-coverage-matrix.md:212-213 records
#5771/#5533 collaboration rows.docs/slate-v2/ledgers/issue-coverage-matrix.md:97-100 records existing
#2288, #1770, #3741, and #4178 operation/collaboration rows.docs/slate-issues/test-candidate-map/5912-5771.md:341-355 identifies
#5771 as ready with minor setup for high-QPS remote insert versus local
selection.docs/slate-issues/test-candidate-map/3797-3708.md keeps #3741 and
#3715 as not direct red-test candidates.docs/slate-issues/test-candidate-map/3313-2733.md keeps #2881 as
architecture/API pressure, not a first-pass red test.No issue coverage matrix, fork dossier, v2 sync ledger, or PR description changes in this pass because no exact issue claim changed.
Full issue-ledger pass result:
docs/slate-v2/references/pr-description.md:43-54 already says the
Yjs/collaboration lane only improves #5771; package source, full example,
package tests, and Playwright selection proof remain required before any
#5771 fixed claim.docs/slate-v2/ledgers/issue-coverage-matrix.md:97-100 already keeps
#2288 at Improves and #1770 / #3741 at Related.docs/slate-v2/ledgers/issue-coverage-matrix.md:212-213 already keeps
#5771 at Improves and #5533 at Related.docs/slate-issues/gitcrawl-v2-sync-ledger.md:93, :178, :276, :278,
:279, :530, :576, and :635 already classify #5771, #5533,
#1770, #3551, #2288, #3741, #3715, and #2881 correctly for this
plan.docs/slate-v2/ledgers/fork-issue-dossier.md:6484-6488 already records the
same collaboration/op-model non-closure stance for #5771, #5533, #2288,
#1770, and #3741.Ledger decision: no durable ledger edit is needed in this pass. This plan adds implementation proof requirements, not a new fixed/improved issue claim. The later execution slice may update ledgers only if browser/package proof justifies a stronger claim.
| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.86 | No React surface change; browser proof still required. |
| Slate-close unopinionated DX | 0.90 | Public API unchanged; raw Slate operation semantics cited. |
| Plate and slate-yjs migration backbone | 0.88 | Keeps state.yjs/tx.yjs and package-owned Yjs policy. |
| Regression-proof testing strategy | 0.90 | Ordered red tests plus browser rows named. |
| Research evidence completeness | 0.91 | Live source, legacy slate-yjs, Lexical, y-prosemirror, related issue discovery, full issue-ledger accounting, and high-risk proof matrix cited. |
| shadcn-style composability | 0.86 | Not UI-facing; no product API added. |
Total: 0.91
Why ready: all Slate Ralplan review passes are complete, the plan has a TDD-first red-test sequence, issue claims stay conservative, and high-risk collaboration limits are explicit. This is ready for a later Ralph/TDD implementation slice; it does not claim the package code is implemented.
Final handoff status: complete.
Closure assertions:
current-state-read, related-issue-discovery, issue-ledger pass, and
high-risk deliberate revision.pending, in_progress, revise,
or blocked.merge_node test and proceeds one vertical
slice at a time.#5771 at Improves, keeps related/non-closure rows
unchanged, and makes no unsupported Fixes claim.move_node limitation.@slate/yjs unit tests, Playwright browser rows, build, typecheck, lint, and
changeset.../slate-v2 source, tests, examples, package, build, or config files.Next owner:
../slate-v2.merge_node plus concurrent edit in the
surviving branch.| Pass | Status | Evidence added | Plan delta | Open issues | Next owner |
|---|---|---|---|---|---|
| current-state-read | complete | Current adapter switch, Slate op union, prior split fix, external Yjs implementations | Created TDD-first plan | Move true identity semantics unresolved | Slate Ralplan |
| related-issue-discovery | complete | Read live ledger, v2 sync ledger, issue coverage matrix, fork dossier, and candidate maps for #5771, #5533, #1770, #2288, #3741, #2881, #3551, and #3715 | Added related issue matrix and preserved no-new-claim stance | None; issue-ledger pass closed the follow-up | Slate Ralplan |
| issue-ledger pass | complete | PR description, v2 sync ledger, issue coverage matrix, and fork dossier rows confirm existing claim stance is already correct | No durable ledger edits; preserved no-new-claim stance | Future execution may update only after package/browser proof | Slate Ralplan |
| high-risk deliberate revision | complete | Expanded trigger, blast radius, six failure scenarios, proof matrix, rollback/remediation, and revised verdict | Made move_node first-stage limitation and no-snapshot fallback rule hard gates | Move subtree stable identity remains a separate future plan | Slate Ralplan |
| closure final gates | complete | Final assertion list, no-pending pass check, edit-boundary check, and Ralph/TDD next owner | Marked plan ready for later implementation without claiming code is implemented | None for planning; implementation remains future work | Ralph/TDD implementation |
merge_node.remove_node.replace_fragment.move_node first-stage bounded support.bun test ./packages/slate-yjs/test/core-contract.tsPLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromiumbun --filter @slate/yjs buildbun --filter @slate/yjs typecheckbun lint:fixWhen executing, start with Red 1 only. Do not implement all four operations in
one shot. If Red 1 does not fail, the test is wrong because current source lacks
merge_node encoding.
Keep move_node claims narrow unless a stable identity design is added.